Repository navigation
linux: a sandboxed command stays in the namespaces made for it - #588
Conversation
Every write deny on Linux is a read-only bind mount, and a bind protects a path only in the mount namespace it was made in. Creating a user namespace needs no capability and gives its creator a full capability set over a private copy of the mount tree, so a command that may create one can take the binds out of its own view. The helper now stops that in two independent ways. A second seccomp filter, stacked on the Unix-socket one, refuses the user-namespace flag to unshare and clone, answers clone3 with ENOSYS so callers fall back to clone, and refuses setns and the calls that change a mount tree. And the helper sets user.max_user_namespaces to zero in the user namespace it made for itself. SRT_ALLOW_NESTED_USERNS=1 in the helper's environment turns both off. SRT_HELPER_FEATURES=1 makes it print what it supports and exit, so a caller can tell this helper from one built before the limit existed.
…config allows it Adds allowNestedUserNamespaces (default false). Every wrap clears the helper's two variables from whatever environment the caller has and sets the opt-out only from the configuration, so an environment block cannot switch the limit off. Where no seccomp helper is in the chain (allowAllUnixSockets, or no helper for the architecture) bubblewrap is told to disable user namespaces, which needs bubblewrap 0.8.0 or later that is not installed setuid. checkDependencies() now also returns what it found as data: features.usernsLimit (true, false, or 'unknown') and details[] with a stable code for the two new warnings, helper_lacks_userns_limit and bwrap_lacks_disable_userns. It asks the helper once per process. What stops working under the sandbox by default is whatever sandboxes itself with user namespaces: a browser's namespace sandbox, rootless podman or buildah, a nested bubblewrap, unshare -r. The README says so, and says that an embedder which compiles the helper into its own binary has to rebuild it.
A live arm opens a directory, makes a user and mount namespace, pivots away from the sandbox's mounts and tries to replace a denied file through the directory it kept; it must be refused and the file on the host unchanged. The same script with allowNestedUserNamespaces on does replace the file, which is what shows the refusing arms can fail. Further arms: both means in force and clone3 answering ENOSYS, threads and child processes still starting, the path with no helper, what the wrap emits, and the helper's answer reaching checkDependencies as data. The dependency tests now let the helper be asked what it supports, and still pin that a non-root caller is never asked about capabilities. One PID namespace test runs the helper by hand inside a wrap that has none, so it says it needs namespaces.
…n one place checkDependencies() could say the limit was in force where the wrap would not impose it: with no seccomp helper and enableWeakerNestedSandbox on, bubblewrap is not given the option to disable user namespaces, but the check reported it as if it were. Both now take the decision from planUsernsLimit(), and that case is reported as not limited, under its own warning code, no_userns_limit_in_weaker_nested_sandbox. A configuration that allows namespaces is reported as not limited too, without a warning. The container suite runs as uid 0, where the helper's filter is the barrier that counts. With the helper an unmount is now refused EPERM by the filter before the kernel looks at the mount, so that expectation changes, and a new case allows namespaces to show the older barrier, the locked mounts and their EINVAL, still standing on its own. Another asks for a user namespace outright and must be refused.
…f filtered too Where the helper cannot mount a fresh /proc (a masked /proc underneath, the enableWeakerNestedSandbox case) the command still sees the helper's outer half. That process shares the command's user namespace, was under no filter and, except for an execute-only binary, was dumpable. A command started by uid 0 holds a full capability set in that namespace, so it could open /proc/<pid>/mem of the outer half, have it run other code with no seccomp filter at all, and from there change the mount tree after all. The helper now clears the dumpable flag before its first fork, whatever it was on entry, so both halves refuse ptrace and every /proc file that asks the same question; the save and restore around the uid and gid map writes goes. The outer half also sets no_new_privs and takes the namespaces filter, which refuses nothing it calls. A non-root caller's command was never able to do this: it holds no capabilities. The filter also refuses open_tree_attr where the libseccomp it is built with can name that call. The container suite gains a case that, as uid 0 with the masked /proc that job has, tries to open the memory and the environment of every helper process it can see. The execute-only test that required a readable copy to stay dumpable now requires the opposite.
…ne, and test the filter itself Asking a helper what it supports ran, for one built before the question existed, whatever its argument named, on the host and outside any sandbox, and the argument was a bare name, so PATH decided. It is now an absolute path in the root of procfs, where nothing can be created. A helper that is part of the caller's own binary is no longer run from the host at all: its path only has to mean something inside the sandbox. The answer for it is 'unknown', and the caller that built it in can ask its own binary. The wrap reaches the helper through a shell, and a shell reads start-up files the caller's environment can name, which could put the opt-out variable back after bubblewrap had cleared it. The helper's command line now starts with an assignment of both of its variables from the configuration, which holds whatever ran before it. The tests could pass with a namespaces filter that held only its clone3 rule. They now make calls that only the filter answers EPERM (a raw clone and an unshare asking for a user namespace, setns, open_tree, mount and umount2 with null arguments, mount_setattr), with a control showing the kernel's own answers where nothing refuses them, and the with-helper arm requires EPERM rather than accepting the limit's ENOSPC in its place. Also: the summary line says when it could not find out whether the helper has the limit; the README says what a root caller's command loses, that the option is one setting for the whole configuration, which callers of clone3 have no fallback, and no longer says that locked mount copies are what hold a root caller's command.
…st calls The namespaces filter resolved every call by name and failed the build on one the library did not know, and mount_setattr is only named from libseccomp 2.5.2, which is newer than what some long-lived build images carry. For that call and for open_tree_attr the generator now falls back to the number, which is the same on every architecture for calls this recent. That works for the builder's own architecture only: libseccomp carries a call from one architecture to another by name and refuses a bare number for another one. So for the other architecture the call is left out, which costs nothing, since a build compiles in its own architecture's filter and no other. An unknown name among the older calls still fails the build. The live test asks for open_tree_attr by number and must be refused, which on a builder whose libseccomp cannot name it is this path.
The generator put the two newest calls into the namespaces filter by number where libseccomp could not name them, which works only for the builder's own architecture, and for another architecture left them out. A filter that is weaker on one architecture with nothing said is the one outcome worth a failed build, so emitting for another architecture with a library that cannot name a call is now an error that names the call, the architecture and the libseccomp version. This package's own build no longer emits for the other architecture at all: the helper it compiles takes the arrays of its own architecture and no other, so the second set was never used. A build that does emit for another architecture needs a libseccomp that names every call; 2.6.1 does.
Every lookup treated only "unknown name" as failure. A name libseccomp knows but reports absent on the architecture comes back as a negative pseudo-number, which seccomp_rule_add() accepts, returning 0 and emitting nothing: the one way left to a filter that quietly lacks a call. Any negative number now takes the same path as an unknown name (by number where that is possible, a refusal otherwise), and the rules are added with seccomp_rule_add_exact(), which errors where the other drops. The filters emitted for x86_64 and aarch64 are byte for byte what they were, and so is the helper. The repository had no test that runs the generator. One now compiles it where a compiler and libseccomp are present, decodes the namespaces filter for the builder's own architecture and looks for all fifteen call numbers in it, and checks that a request for the other architecture it cannot complete exits non-zero naming the call. Comments in both C files that later commits of this branch had made untrue are corrected; apply-seccomp.c changes in comments only.
To learn whether a helper file can limit user namespaces the library ran it, with spawnSync, in its own process context: from the dependency check and from every wrap. Until this branch that file was only ever named inside the wrapped command line, so it only ever ran inside bubblewrap. Where the sandbox policy lets a wrapped command write the helper (an operator-named seccomp.applyPath inside a write root, the home-directory fallbacks), replacing it meant code running unsandboxed in the next process that checked dependencies or wrapped a command. The question is now put inside bubblewrap: user, mount, pid, network, ipc and uts namespaces of its own, the root bound read-only, no capabilities, a new session, PATH as the only environment. The user namespace is required, as it is for every wrap, so where none can be made the helper is not run at all. "Could not be asked" (no bubblewrap, bubblewrap failing under its own name, a silent failure, a time-out) is 'unknown' and never a reason to run the helper directly; only an answer is remembered, except a time-out, which is not paid for twice. Also: seccomp.applyPath must be absolute unless argv0 is set; an empty argv0 counts as not set in the one place both the check and the wrap use; bubblewrap is looked up only on the path that needs the answer (one lookup per wrap under Node where there were two); an empty bwrapPath no longer makes the wrap throw. The README names every call the filter refuses, says what holds without the seccomp layer, and describes what a refusal looks like.
Each of these failed to fail when the code it is about was taken out. - Six of the filter's rules (pivot_root, move_mount, fsopen, fsconfig, fsmount, fspick): probed as uid 0 in the container suite, EPERM each by default and the kernel's own answers where namespaces are allowed; fsconfig also on the host leg, where the kernel's answer differs. - The route that needs no unmount and, for uid 0, no namespace: private mount tree, tmpfs, pivot_root, lazy detach, then the denied file moved aside and written afresh. Refused step by step by default; going through, and replacing the file on the host, where namespaces are allowed, which is what the option gives up and shows the probe can tell. The case whose title promised more than it checked is renamed. - The outer half of the helper takes the namespaces filter, and leaves it out under the opt-out: read from /proc/<pid>/status. - The no-helper cases are gated on bubblewrap's own answer, not on the function under test; bwrapCanDisableUserns and its warning code are tested with stand-in bubblewraps, one of them setuid. - Opting out keeps the Unix-socket filter, on both legs. - SandboxManager.checkDependencies() passes on features and details and forwards the three options; the wrap carries the assignment. - The probe never runs a helper file outside bubblewrap: a stand-in reports its namespaces, session and environment, from the dependency check and from a wrap; a failing bubblewrap is 'unknown' and asked again; a time-out is not; none of it as uid 0 needs a fresh /proc. - The probe caches are reset between test files that mock them.
Comments only: the JavaScript emitted with comments removed is byte-identical for every file, and the test names are unchanged. Kept: every invariant and what could be done without it, the documentation of exported names, and the reasons for what is not done the obvious way. Cut: how the code came to be as it is, narration of the lines that follow, and explanations repeated at several sites.
Stays: the namespaces seccomp filter, the generator failing on a call it cannot name, the helper installing the filter and setting user.max_user_namespaces to zero, the helper non-dumpable before its first fork with its outer half filtered too, and the allowNestedUserNamespaces option, taken from the configuration alone. Leaves, to come back as changes of their own: asking the helper what it supports and reporting it through checkDependencies(), the --disable-userns path for a wrap with no helper, and the rule that seccomp.applyPath be absolute. With no helper in the chain, or a compiled-in helper older than this, nothing limits namespaces: the README says so. The emitted filters are byte-identical to before.
antdres
left a comment
There was a problem hiding this comment.
The fix holds. As a non-root user and as root, the pivot_root chain from the new test replaces a write-protected .git/config on the merge-base and is refused on this head (unshare gives EPERM, user.max_user_namespaces reads 0, clone3 gives ENOSYS and threads and subprocesses still start, the 32-bit and x32 entry points are killed as before). A caller-side SRT_ALLOW_NESTED_USERNS=1 does not lift it. The new tests fail when I swap in a helper built from the merge-base.
One thing to decide before merging, because it is on by default: anything that sandboxes itself with user namespaces stops working inside srt. Run srt -s settings.json -- <path to chromium headless_shell> --headless --dump-dom 'data:text/html,<p>x</p>'. On main it prints the DOM. On this PR it dies with "FATAL ... No usable sandbox!" (Trace/breakpoint trap). Same for unshare -Ur true (EPERM) and bwrap --ro-bind / / true inside the sandbox. With "allowNestedUserNamespaces": true all three work again, and Chromium with --no-sandbox works either way. Playwright and Electron users will hit this on upgrade, so I would want it called out in the release notes at least, or the consumers that launch browsers set the option first.
Smaller: a command started as root could unshare -m inside the sandbox on main and now gets EPERM (mount calls are filtered too).
(The seccomp helper for these runs was built locally against libseccomp 2.5.5; arm64 was not run.)
Generated by Claude Code
A command started by uid 0 holds the capability to make a mount namespace with no new user namespace. From Linux 7.1 unshare(2) can make one that holds the root mount and none of those beneath it. unshare is refused with CLONE_NEWNS and with that flag as well, clone with CLONE_NEWNS. The rule table takes a set of flags for a call, one rule a flag. To clone the flag's bit is CLONE_PARENT_SETTID, which threads are made with, so it is left alone there.
Read from the configuration passed to wrapWithSandbox before the session's, as allowPty is. One command that has to sandbox itself can then be let, and keeps every other limit, while the commands beside it keep this one too.
#617) Squash merge of 8 commits
On Linux, a sandboxed command that runs under the seccomp helper stays in the namespaces the sandbox made for it: it cannot create a user namespace or a mount namespace of its own, join another, or change a mount tree. The sandbox's denies are mounts, and a mount holds in the namespace it was made in.
On by default, and a change of behaviour. See "Who sees a difference".
This branch now holds #617 too, which was stacked on it and has been merged into it (
75b39fe: four files, +219). So merging this PR lands both. See "With no helper" below, and #617 for its own description and review.What changes
namespaces, is stacked on the command.unshareandclonewithCLONE_NEWUSERorCLONE_NEWNS,setns,mount,umount2,pivot_rootand the fd-based mount calls answerEPERM.unsharewithUNSHARE_EMPTY_MNTNS(Linux 7.1) too. Toclonethat bit isCLONE_PARENT_SETTID, which threads need, so it is left alone there.clone3answersENOSYS, so libc falls back toclone.user.max_user_namespaces=0in its user namespace.allowNestedUserNamespaces(defaultfalse) lifts all of it, for the session or for a single wrap (in the configuration passed towrapWithSandbox, asallowPtyis). It reaches the helper from the configuration. A variable in the caller's environment does not.Who sees a difference
Whatever sandboxes itself with namespaces fails inside the sandbox, unless the option is on:
unshare -r,unshare -Urmunshare: unshare failed: Operation not permittedbwraporsrtbwrap: No permissions to create a new namespace…No usable sandbox!(works with--no-sandbox)EPERMmount,umount,setnsandunshare -m.vendor/seccomp-srcgets the limit only with a rebuild. An older helper ignores the setting: everything starts, and nothing is limited.Testing
truelifts the limit.unshare(CLONE_NEWNS),clone(CLONE_NEWNS)and the 7.1 flag answerEPERM, and a thread still starts.EPERM. It fails with a helper built from the commit before.Not covered
allowAllUnixSockets: true, or no helper for the architecture): nothing limits namespaces there. linux: with no seccomp helper, have bubblewrap disable user namespaces #617, stacked on this, does.With no helper (#617)
A wrap with no helper in its chain (
allowAllUnixSockets: true, or no helper for the architecture) gets none of the above. It now gives bubblewrap--disable-userns, unlessallowNestedUserNamespacesorenableWeakerNestedSandboxis on.unshare -rand a nestedbwrapfail withENOSPC("No space left on device"), notEPERM.