Skip to content

linux: drop a root caller's capabilities in both sandbox modes - #505

Merged
dylan-conway merged 3 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-weaker-root-caps
Sep 3, 2026
Merged

linux: drop a root caller's capabilities in both sandbox modes#505
dylan-conway merged 3 commits into
anthropics:mainfrom
ronleizrowice-ant:fix/linux-weaker-root-caps

Conversation

@ronleizrowice-ant

Copy link
Copy Markdown
Contributor

On Linux, bubblewrap run by uid 0 hands the sandboxed command every capability the caller holds unless told to drop them, and enableWeakerNestedSandbox never told it to. Inside bwrap's user namespace CAP_SYS_ADMIN is enough to umount a read-deny tmpfs or a write-deny bind, or to remount / read-write — the whole filesystem policy. Any embedder that runs as root with capabilities (a VM, a --privileged or --cap-add SYS_ADMIN container) and sets enableWeakerNestedSandbox: true had an advisory sandbox: reproduced in an Ubuntu 24.04 container with --cap-add SYS_ADMIN, where a sandboxed command read a denyRead file after umount, appended to a denyWrite directory (persisted on the host), and created /etc/planted after mount -o remount,bind,rw /. Strict mode already passed --cap-drop ALL, but for a root caller it never started: the seccomp helper's nested user namespace must map uid 0, which the kernel (5.12+) allows only if the namespace's creator held CAP_SETFCAP, so every command died with apply-seccomp: write /proc/self/uid_map: Operation not permitted — which is what pushes root embedders to the weaker mode in the first place.

Fix: a root caller drops every capability in both modes and keeps exactly CAP_SETFCAP while the seccomp helper is in use and the caller has it (rootCapabilityArgs, reading CapEff from /proc/self/status). The helper loses that capability on entering its nested namespace, so the command runs with none in bwrap's namespace; the copies of bwrap's mounts in the nested namespace are locked by the kernel, so the full capability set the command holds there cannot lift them (verified: umount, remount and write all refused with CapEff: 000001ffffffffff inside). Without the helper (allowAllUnixSockets: true) it is a plain --cap-drop ALL. A non-root caller is unchanged (nothing to drop, nothing it may add).

Effects, verified in the container as uid 0, with Docker's default capabilities, with --cap-add SYS_ADMIN, and --privileged, on bubblewrap 0.9.0: weaker mode closes all three escapes and still starts, seccomp unix-socket block intact; strict mode now starts for a root caller (fresh /proc and all) instead of dying at uid_map — the full suite as uid 0 goes from 109 failures on main to 8 (three are environmental in that container; five are tests that run apply-seccomp standalone or assert a non-root uid). As uid 1001 the suite matches main.

Tests: wrap-with-sandbox.test.ts pins the weaker branch's arguments (and --cap-drop ALL when the runner is root); the Docker e2e gains a case that greps CapEff, tries umount of a read-denied directory and of the work dir plus a remount of /, and asserts the secret stays hidden and the write fails; the CI docker job installs libcap2-bin, runs with --cap-add SYS_ADMIN, and executes the e2e twice — holding the capability (a VM's root; red on main) and with it dropped via capsh (the unprivileged container the mode exists for).

Blast radius, not behind a flag: root callers only. A sandboxed command run by a root embedder can no longer create a user namespace that maps uid 0 (unshare -Ur fails), as was already the case in strict mode; and a root caller without CAP_SETFCAP in its effective set gets no --cap-add, so the seccomp helper fails as strict mode did before (it failed there already: the nested mapping needs it). Follow-up worth doing in apply-seccomp.c: capset to empty before execvp when euid is 0, so the workload holds no capabilities even in the nested namespace (defence in depth; needs the vendored binaries rebuilt).

bubblewrap run by uid 0 hands the command every capability the caller
holds unless told to drop them, and the weaker nested mode never did:
with CAP_SYS_ADMIN a sandboxed command could unmount a read-deny tmpfs or
a write-deny bind, or remount / read-write. Strict mode dropped them but
never started for root, because the seccomp helper's nested user
namespace must map uid 0, which the kernel allows only when the
namespace's creator held CAP_SETFCAP. A root caller now drops everything
in both modes and keeps only CAP_SETFCAP while the helper is in use; the
helper loses it on entering its namespace, whose copies of the mounts
are locked. The Docker e2e gains an unmount case and runs in CI with and
without CAP_SYS_ADMIN.
The command holds no capability in bwrap's user namespace whoever the
caller is: strict mode already passed --cap-drop ALL unconditionally, and
for a non-root caller the drop is a no-op, so the weaker mode can pass the
same list instead of dropping only for uid 0. capabilityArgs() now always
starts with --cap-drop ALL and adds CAP_SETFCAP only for a root caller
using the seccomp helper; both branches push it and differ only in how
/proc is mounted. The weak-mode unit test expects the drop for any
caller, and the docker test loses a CapEff read it never asserted on.
@dylan-conway
dylan-conway merged commit e3ac597 into anthropics:main Sep 3, 2026
8 checks passed
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