Skip to content

fix: refuse vsock and io_uring in the Linux sandbox - #14

Merged
sstriker merged 4 commits into
mainfrom
fix/sandbox-seccomp
Oct 24, 2026
Merged

sstriker merged 4 commits into
mainfrom
fix/sandbox-seccomp

Conversation

@sstriker

@sstriker sstriker commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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/sandbox had no seccomp filter and did not set PR_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 with seccompiler 0.5.0 (Apache-2.0 OR BSD-3-Clause, Linux only), before the fork. It refuses with EPERM:

    • socket and socketpair with family AF_VSOCK;
    • io_uring_setup, io_uring_enter and io_uring_register;
    • on x86_64, the same five calls by their x32 numbers. x32 calls have the x86_64 audit architecture, so the architecture check does not stop them.

    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 sets PR_SET_NO_NEW_PRIVS and installs the filter as its last step. A failure is reported with libc::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 an fcntl loop up to RLIMIT_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(), which amem doctor uses, now also builds and installs the filter in its probe child. So doctor and the launch agree where an outer seccomp profile refuses seccomp(2).

Failing first

cargo test -p dm-sandbox --test seccomp against main: exit 101.

  left: {"io_uring_enter": "9", "io_uring_register": "22", "io_uring_setup": "ok", "no_new_privs": "0", "socket_inet": "ok", "socket_unix": "ok", "socket_vsock": "ok", "socket_vsock_x32": "ok", "socketpair_vsock": "9"}
 right: {"io_uring_enter": "1", "io_uring_register": "1", "io_uring_setup": "1", "no_new_privs": "1", "socket_inet": "ok", "socket_unix": "ok", "socket_vsock": "1", "socket_vsock_x32": "1", "socketpair_vsock": "1"}

Against 0d2fe64, before the second commit:

  • Inherited sockets: "left_open_unix": "ok", "left_open_vsock": "ok" where EBADF (9) is expected. The vsock socket was really inherited on a WSL2 host.
  • doctor: availability() returned ok under a filter that refuses seccomp(2), and the launch was then refused.
  • x32 rules for io_uring removed (a mutation): "io_uring_setup_x32": "ok". That is a real bypass, and the new test catches it.
  • fcntl fallback removed (a mutation): without_close_range_a_socket_left_open_is_still_not_inherited fails.

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:

  • i386 calls die with SIGSYS.
  • Node 24 (libuv 1.52.1) falls back cleanly when io_uring is refused.
  • The Windows and macOS builds are clean.
  • cargo deny and cargo machete pass.
  • Nothing is allocated between fork and exec.

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 that io_uring_disabled=2 can hide the io_uring_setup assertion. The test now checks that setting.

Notes for review

Posted by Claude on behalf of @sstriker

sstriker and others added 2 commits October 24, 2026 13:34
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>
@sstriker
sstriker requested review from andrewmusselman and a balanced review from Copilot October 24, 2026 12:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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, denying socket/socketpair with AF_VSOCK and all io_uring_* calls (including x32 numbers on x86_64), plus PR_SET_NO_NEW_PRIVS.
  • In pre_exec, mark every fd ≥ 3 close-on-exec (close_range with an fcntl fallback) and install the filter last; teach availability() (used by amem 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

sstriker and others added 2 commits October 24, 2026 17:47
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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

@sstriker
sstriker merged commit 3861435 into main Oct 24, 2026
13 checks passed
@sstriker
sstriker deleted the fix/sandbox-seccomp branch October 24, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants