Repository navigation
fix: refuse vsock and io_uring in the Linux sandbox - #14
Conversation
A network namespace does not cover AF_VSOCK. On WSL2 and on other VM guests, a vsock socket can reach the hypervisor host. io_uring can make a socket without a socket() call. The namespaces backend now sets PR_SET_NO_NEW_PRIVS. It then installs a seccomp filter as the last step before exec. The filter returns EPERM for socket() and socketpair() with family AF_VSOCK. It returns EPERM for io_uring_setup, io_uring_enter and io_uring_register. It also refuses the x32 numbers of these calls on x86_64. All other calls are allowed. This is the rule set and the errno that Codex uses. The seccompiler crate compiles the filter before the fork. The install step does not allocate. A call from a different syscall ABI, for example i386 on x86_64, kills the process. The test runs a probe inside the sandbox and expects each refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A socket that the launcher left open without close-on-exec stayed open inside the sandbox. A vsock socket made outside the filter can reach the host. The backend now marks each descriptor from 3 up close-on-exec before it installs the filter. It uses close_range with CLOSE_RANGE_CLOEXEC. If the kernel does not have it, the backend sets FD_CLOEXEC on each descriptor below the open-files limit. The launcher passes no descriptor above 2 into the sandbox. availability() said Ok when a seccomp profile refused seccomp(2). Then every launch failed. The probe child now installs the same filter that a launch installs. If that fails, availability() and the launch give the same reason. The test now probes the x32 numbers of all refused calls on x86_64. It does not assert io_uring_setup when kernel.io_uring_disabled is not 0. New tests check inherited descriptors, the fallback without close_range, and availability under a profile that refuses seccomp(2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It changes security-critical confinement with unsafe seccomp BPF, x32 ABI handling, between-fork-exec constraints, and close-on-exec guarantees that warrant human verification.
No open issues 🎉
What changed in this PR
This PR closes a confinement gap found by the harness-container survey (#2): a Linux network namespace does not cover AF_VSOCK, so a sandboxed process could reach the hypervisor host (notably on WSL2). It adds a seccomp filter to crates/sandbox that refuses vsock and io_uring, installed as the last step before exec, and also closes inherited descriptors so a vsock socket the launcher held open cannot slip into the box.
Changes:
- Add
src/linux/seccomp.rs(seccompiler 0.5.0) that compiles a filter before fork and installs it after, denyingsocket/socketpairwithAF_VSOCKand allio_uring_*calls (including x32 numbers on x86_64), plusPR_SET_NO_NEW_PRIVS. - In
pre_exec, mark every fd ≥ 3 close-on-exec (close_rangewith anfcntlfallback) and install the filter last; teachavailability()(used byamem doctor) to probe filter installation so doctor and launch agree. - Add integration tests (
tests/seccomp.rs,tests/common/mod.rs) that run probes inside the sandbox, covering vsock/io_uring refusal, x32 bypass, inherited descriptors, and the no-seccomp profile case.
| File | Description |
|---|---|
crates/sandbox/src/linux/seccomp.rs |
New seccomp filter builder/installer (vsock + io_uring, x32-aware). |
crates/sandbox/src/linux.rs |
Installs filter + no_new_privs last in pre_exec; adds cloexec_from_three/open_files_limit/say; extends availability() with a seccomp probe and NO_SECCOMP reason. |
crates/sandbox/tests/seccomp.rs |
Integration tests for refusal, x32, inherited descriptors, and no-seccomp. |
crates/sandbox/tests/common/mod.rs |
Shared helper to run an ignored probe test inside the namespaces sandbox. |
crates/sandbox/Cargo.toml |
Adds seccompiler = "0.5.0" as a Linux-only dependency. |
Cargo.lock |
Records seccompiler 0.5.0 and its libc dependency. |
Review effort: Balanced
The seccomp tests skipped when availability() failed. On CI they skipped without a trace, so they did not test anything there. The new helper common::confinable() calls availability(). If the sandbox cannot run and the CI variable is set, the helper fails the test and gives the reason. If CI is not set, the helper prints the reason and the test skips. Each test that skipped now uses the helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It modifies a security-critical confinement boundary with post-fork unsafe code, hand-written seccomp BPF, raw syscalls, and a new dependency, which warrants final human review despite no defects found.
No open issues 🎉
Review effort: Balanced
This PR fixes one of the gaps that the harness-container survey (#2) found in our code. The Linux sandbox did not cover vsock (
docs/harness-containers.md, "vsock").The problem
A network namespace does not cover
AF_VSOCK. Inside our sandbox, a process could create a vsock socket. On WSL2, or on any VM guest with a vsock device, that socket reaches the hypervisor's host.crates/sandboxhad no seccomp filter and did not setPR_SET_NO_NEW_PRIVS. Codex and sandbox-runtime refuse vsock and io_uring for this reason.The change (
crates/sandbox)The filter.
src/linux/seccomp.rs(new) builds a seccomp filter withseccompiler0.5.0 (Apache-2.0 OR BSD-3-Clause, Linux only), before the fork. It refuses with EPERM:socketandsocketpairwith familyAF_VSOCK;io_uring_setup,io_uring_enterandio_uring_register;A call from a foreign ABI (i386 through
int 0x80, aarch32) kills the process, as in Codex. On an architecture other than x86_64, aarch64 or riscv64, the launch fails.Installing it. In
pre_exec, after the namespace and root setup, the child setsPR_SET_NO_NEW_PRIVSand installs the filter as its last step. A failure is reported withlibc::write, and nothing is allocated.Inherited descriptors. Before it installs the filter, the child marks every descriptor from 3 up close-on-exec. It uses
close_range(3, ~0U, CLOSE_RANGE_CLOEXEC), and on a kernel before 5.11 anfcntlloop up toRLIMIT_NOFILE. Without this, a vsock socket that the launcher held open passed into the box and reached the host. The launcher passes no descriptor above 2 on purpose: the relay gets the egress socket as a path, and the pty is fds 0-2.availability(), whichamem doctoruses, now also builds and installs the filter in its probe child. So doctor and the launch agree where an outer seccomp profile refusesseccomp(2).Failing first
cargo test -p dm-sandbox --test seccompagainstmain: exit 101.Against
0d2fe64, before the second commit:"left_open_unix": "ok", "left_open_vsock": "ok"where EBADF (9) is expected. The vsock socket was really inherited on a WSL2 host.availability()returnedokunder a filter that refusesseccomp(2), and the launch was then refused."io_uring_setup_x32": "ok". That is a real bypass, and the new test catches it.fcntlfallback removed (a mutation):without_close_range_a_socket_left_open_is_still_not_inheritedfails.make check: 623 tests, 623 passed, 3 skipped. The 3 skipped are probe binaries that the tests run inside the sandbox.Adversarial pass
A fresh agent attacked
0d2fe64. It verified these points:cargo denyandcargo machetepass.It found three gaps, all fixed in
2a84eb1: the x32 test gap, the inherited descriptors, and doctor's disagreement with the launch. It also found thatio_uring_disabled=2can hide theio_uring_setupassertion. The test now checks that setting.Notes for review
maininto this branch and run CI again. Before this change, the tests skipped on CI: in run 40096054625 they reportedokabout 2 ms after the binary started. The evidence above is from a WSL2 host with kernel 6.18.availability(). The second one to merge needs a small resolution: the probe child returns 1 for the user namespace, 2 formount_setattrand 3 for seccomp.Posted by Claude on behalf of @sstriker