The Fix Passed CI. io_uring Walked Right Through It.
Last post: 13 isolation checks passed while a Unix socket sat open. Those checks weren't nothing. They covered credentials, capabilities, broker auth, scope, revocation, budgets. But none of them checked whether the age
Last post: 13 isolation checks passed
while a Unix socket sat open. Those checks weren't nothing. They covered credentials, capabilities, broker auth, scope, revocation, budgets. But none of them checked whether the agent started with leftover file descriptors, or whether it could make a socket the host could reach. The tests couldn't fail on stuff they never looked at.
Then the comments showed up and basically wrote my test plan. Every finding got the same deal: make it go red on the current code first, then fix it, then show it go green. One question found a real hole. My first fix for it got bypassed.
| Comment | What it asked | Result |
|---|---|---|
| Nan | Do already-open fds survive the boundary? | Gap closed: exact fd check at startup, broker rejects passed fds |
| Vladimir | Has your check ever been shown to fail? | Socket count with two planted controls that must go red |
| Panth | Do tests check the exact rejection reason? | Found a test passing for the wrong reason |
| Max | Can the agent create a socket the host connects to? | Real hole. Fixed, fix bypassed, fixed again |
All of this ran in GitHub Actions on x86-64 Ubuntu 24.04. The results below are what I recorded in CI. The repo and logs are private for now, so the evidence section at the end lists the exact commits and run IDs.
1. Old connections walk right through
Nan pointed out that entering a new network namespace doesn't revoke sockets that were already open before it. They keep working. So the checklist has to start with "no unexpected inherited fds survive the boundary", not "no new connections possible".
The agent's first action is now to list /proc/self/fd and fail closed unless the set is exactly 0, 1, 2. It runs before the agent even opens its broker socket. It's an enumeration, not a hand-written list of things to look for.
Two limits on that. It checks which fd numbers are open, not what 0, 1 and 2 point to, so it catches extra descriptors rather than validating stdio itself. And it runs inside the agent's startup code. That makes it a regression check on the launcher, not enforcement against code that's already hostile.
To prove the check can see, two red controls hand the agent an inherited socket and an inherited file. Both must exit with the leak code.
There's a second door going the other direction: the agent could hand fds to the broker, because a Unix socket can carry them via SCM_RIGHTS, not just messages. The broker is the trusted side, so it shouldn't end up holding anything the agent gave it. It now rejects any message with ancillary data and closes every fd the kernel delivered. The end-to-end test against the live broker:
replies: passed_fd_rejected x3
broker fds: 0,1,2,3 before -> 0,1,2,3 after
budget: 6 remaining -> 6 remaining
valid GET: 6 remaining -> 5 remaining
That last line matters. The first version of this test said "spends no budget" while the ledger didn't exist, so nothing could be spent. The valid request proves the ledger test can see spending.
2. Count, don't name
Vladimir's comment is the one I keep coming back to: 13 of 13 is the number to stop trusting first. A check that has only ever passed hasn't proven it can see. The first post already had a socket inventory that expected exactly one: the broker's. What it didn't have was any proof the inventory could fail. That's what changed here.
Counting with find / -type s has a blind spot, though. As an unprivileged user, a socket inside a directory you can traverse but not list stays connectable while find skips it. It only prints "Permission denied". There's no find in the jail anyway, so the agent walks the filesystem itself, and any directory it can't list is a failure, not a skip.
Two planted controls have to go red for the count to mean anything:
| Control | Expected | Observed |
|---|---|---|
Extra socket at /run/extra.sock
|
exit 2 | exit 2, sockets=2, never reached the broker |
| Unlistable directory | exit 2 | exit 2, EACCES (13), never reached the broker |
| Clean jail | pass | sockets=1, errors=0 |
The controls are injected by a test-only wrapper that lives outside the jail. The real jail binary refuses those flags, and the agent was checked for any trace of them in its files, arguments and environment.
Vladimir also added that a red control only counts on the platform where it actually went red. So the workflow pins ubuntu-24.04 and every evidence artifact records the runner's image version and kernel. The label is pinned, but GitHub's image revision under it is not, which is why it gets written down next to each result.
3. Right result, wrong rule
Panth asked whether my tests check the exact rejection reason on every rejection, or only where two rules can overlap. Honestly, I didn't know, so I checked. Not every one. A bunch of them just asserted "denied".
So I pinned every reason, and the first strict run failed:
[FAIL] redirect into forbidden host blocked
expected redirect_host_not_in_scope
got redirect_port_not_allowed
The redirect pointed at port 80, the signed authorization only allowed the mock server's port, and the port check runs before the host check. The request was still blocked, so the old test (anything starting with redirect_) passed. It just never exercised the rule it was named after. Same shape as the signed-port finding in the first post.
The mapped-IP test had the same problem. An IPv4-mapped private address came back non_loopback_target, meaning the loopback-only rule caught it before the mapped-address handling ever ran. So I added a positive test with a mapped form of an allowed address. ::ffff:127.0.0.1 is recognized as loopback, passes the signed-IP gate, and reaches an IPv4-only mock server end to end. Forbidden mapped addresses are still only caught by the loopback rule, so a mapped-specific negative test is deferred until that rule loosens.
Two reasons, mapped-IP and foreign-uid, also got a check where I changed the expected reason to a wrong one, confirmed the test went red, then restored it. To be clear about what that shows: the assertion really compares the reason. It doesn't show the test would catch broken enforcement. That's a weaker control than planting a socket or bypassing a rule, and the other pinned reasons haven't had even that yet.
One more thing came out of this. A foreign uid is stopped twice. Socket file permissions reject it at connect (EACCES, and the broker never sees the request). Anything that gets past that hits the broker's own SO_PEERCRED check, peer_not_authorized. Each layer has its own test.
I kept a couple of assertions loose on purpose. Kernel errno for a firewall denial and TLS-vs-connect errors can vary by environment, so pinning one value just makes a test flaky. Those are commented, and they assert the result is never a policy rejection code, so a policy rule can't quietly be the thing that blocked them.
4. The socket going the other way
Max asked two things. First, was the "whitelist" from the first post a path check in the broker at connect time? No, and that was loose wording on my part. The broker only sees connections made to itself. The real control is the mount namespace. At the inventory checkpoint, the only pathname socket in the jail is the broker's. That says nothing about sockets created later, which turned out to be the point.
His second question is the one that got me. Could the agent create a socket that something privileged on the host later connects to?
The jail has a private, writable /tmp. The socket count only runs at startup, and nothing stopped a bind afterwards. A socket in the jail's /tmp doesn't show up in the host's /tmp, but it's reachable through /proc/<pid>/root/tmp/. The red test on unmodified main:
agent_uid=65534
bind_errno=0 listen_errno=0
host_uid=65534 host_connected=true
On the tested runner, a host process running as uid 65534 connected. The agent ran as 65534, which is nobody, the uid plenty of host services share. A host process with a different uid got EACCES. Access through /proc/<pid>/root also goes through a ptrace access check, so the exact rules depend on configuration.
To be precise about what this shows: a host process connected on purpose to a listener the agent made. It doesn't show an existing privileged service finding that listener on its own or being exploited through it. It's a channel that shouldn't exist, not a demonstrated attack.
He also asked whether a user namespace (-Urn) changes any of this. In a quick test outside this repo, unshare -rn left a host pathname socket connectable: file access inside the namespace maps back to your outside uid, so you reach whatever that uid could already reach. I didn't treat adding a user namespace as isolation for this, and it isn't part of the fix.
The fix had two parts:
- The agent runs as uid 65531, and the jail refuses to launch if any host process is already using that uid. That doesn't stop a privileged host process from adopting it later.
- A seccomp filter denies
bind()andlisten(). The agent only ever needs to connect to the broker.
5. The fix got bypassed
The first version of that seccomp filter passed CI. Direct bind() and listen() returned EPERM, and the host couldn't connect. It looked fixed.
It wasn't. Since Linux 6.11, io_uring has its own bind and listen operations. Seccomp can filter the io_uring syscalls themselves, but a bind submitted through io_uring isn't a bind() syscall, so a rule on bind() never fires. The runner's kernel is 7.0. Red test against the "fixed" code:
direct_bind_errno=1 (EPERM)
direct_listen_errno=1 (EPERM)
io_uring_setup=0 IORING_OP_BIND=0 IORING_OP_LISTEN=0
host_connected=true
The filter now also denies io_uring_setup, io_uring_enter and io_uring_register. It checks the architecture before anything else and kills the process on a mismatch. That alone doesn't catch x32, which reports the same architecture as x86-64, so it also kills any syscall number with the x32 bit set. Without those checks, a 32-bit or x32 call could bypass a filter that only accounts for 64-bit syscall numbers.
Each layer was tested on its own, all in one CI run:
| Probe | Result |
|---|---|
| uid 65531, seccomp off: host uid 65534 connects | EACCES |
| seccomp on: direct bind / listen | EPERM |
| seccomp on: io_uring setup / enter / register | EPERM |
| x32 syscall under seccomp | killed, SIGSYS |
| agent to broker | works |
The jail also refuses to launch on anything except x86-64. The ARM64 version of the filter has never been run, so it doesn't get to run quietly.
Also, I messed one up on the way. A baseline test expected EBADF from an invalid io_uring_register call and got EINVAL, and the expected value got changed to match the output. That's exactly the thing this whole post is against. Reverted it, and swapped in a real registration that works before the filter and gets denied after it.
What this still doesn't cover
This is CI evidence for the cases tested, not a claim that the jail is safe in production.
-
One platform. Everything ran on x86-64 Ubuntu 24.04 runners. ARM64 is refused, not tested. Native 32-bit
int 0x80calls weren't executed; the architecture check that should kill them was verified by reading the filter, not by running one. - Host root still wins. A dedicated uid doesn't stop a privileged host process from adopting it. Seccomp is what blocks the listener; the uid is a second layer.
- The filter is a deny list. io_uring is the proof that new kernel features open routes nobody blocked. An allow list of only the syscalls the agent needs is the next step.
- Checks are snapshots. The fd check runs at startup and again before each broker request, not continuously.
- Still open from the first post. The broker still isn't filesystem-sandboxed, there are no resource limits (cgroups) yet, and the agent is still loopback-only. Nothing here changed those.
- One agent at a time. Agentic Affairs asked about running several agents. Until the broker can tell agents apart (a socket or uid per agent), concurrency is a shared-identity problem. Not built yet.
Evidence
Each result above maps to the code that was tested, the commit the workflow ran from, and a GitHub Actions run. The red probes reproduced the unwanted behavior. Two early workflows still ended in failure because additional assertions did not pass. The table separates the reproduction from the overall CI result.
| Finding | Tested code | Workflow commit | Run | Workflow result |
|---|---|---|---|---|
| Reverse socket reproduced |
8fbfddd (main before fix) |
5d9bb583 |
42904285769 | Failed: vulnerability reproduced, but the candidate check failed because it wasn't hardened yet |
| Reverse socket red and green | baseline + candidate | 4edd0c13 |
42904567172 | Passed |
| io_uring bypass of the first fix |
60934c11 (first filter) |
748c638e |
42907967889 | Failed: bypass reproduced, plus the unrelated EBADF assertion from section 5 |
| Each layer tested alone | hardened candidate | bceae5c5 |
42908824816 | Passed |
| All four gates (fd guard, isolation, unix socket, seccomp edge) after PR #15 merge | 45714cb |
45714cb |
42918048152, 42918048113, 42918048139, 42918048133 | Passed |
The repository and its Actions logs are currently private. These identifiers preserve the tested revisions and runs, but public readers can't inspect them yet. A sanitized reproduction and logs have not been published.
What I'm taking from this
- Red first, every time. Two of these tests were passing for reasons that had nothing to do with their names. I only found out because I made them fail first.
- Pin the reason, not just the outcome. "Denied" passes even when the wrong rule caught it.
- Blocking the obvious path isn't the fix. io_uring walked straight past a filter that passed CI.
- Don't change an expected value just to make the output pass. Sometimes the expectation really is wrong, but you find that out by checking, not by matching. I learned that one the annoying way.
Thanks to Nan, Vladimir, Panth, Max, Mudassir, Alex, Oleksandr and Agentic Affairs. Honestly, most of this post is just your questions with test output attached. Keep them coming.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.