Skip to content

linux: a sandboxed command stays in the namespaces made for it - #588

Merged
dylan-conway merged 19 commits into
mainfrom
fix/linux-no-nested-user-namespaces
Oct 14, 2026
Merged

dylan-conway merged 19 commits into
mainfrom
fix/linux-no-nested-user-namespaces

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • A second seccomp filter, namespaces, is stacked on the command.
    • unshare and clone with CLONE_NEWUSER or CLONE_NEWNS, setns, mount, umount2, pivot_root and the fd-based mount calls answer EPERM.
    • unshare with UNSHARE_EMPTY_MNTNS (Linux 7.1) too. To clone that bit is CLONE_PARENT_SETTID, which threads need, so it is left alone there.
    • clone3 answers ENOSYS, so libc falls back to clone.
    • The generator fails rather than write a filter that lacks a call it cannot name.
  • The helper sets user.max_user_namespaces=0 in its user namespace.
  • The helper is non-dumpable before its first fork, and its outer half takes the filter too.
  • New option allowNestedUserNamespaces (default false) lifts all of it, for the session or for a single wrap (in the configuration passed to wrapWithSandbox, as allowPty is). 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:

what it says
unshare -r, unshare -Urm unshare: unshare failed: Operation not permitted
a nested bwrap or srt bwrap: No permissions to create a new namespace…
Chromium, Electron, Playwright, Puppeteer No usable sandbox! (works with --no-sandbox)
rootless podman, buildah EPERM
  • Threads, worker threads, forks and pipelines work as before.
  • A command started by uid 0 also loses mount, umount, setns and unshare -m.
  • Whoever builds or embeds the helper from vendor/seccomp-src gets the limit only with a rebuild. An older helper ignores the setting: everything starts, and nothing is limited.

Testing

  • Each build with a helper built from its own sources, real bubblewrap, Bun and Node: the table above; with the option on, all of it works; a write-denied file stays as it was.
  • Twelve wraps at once from one session, every other one given the option: each as given. A wrap with the option keeps its file, network and socket limits. Nothing but true lifts the limit.
  • As uid 0 of a user namespace: unshare(CLONE_NEWNS), clone(CLONE_NEWNS) and the 7.1 flag answer EPERM, and a thread still starts.
  • A live test asks each call so that the kernel, asked, would say something other than EPERM. It fails with a helper built from the commit before.
  • The generator's test; config validation; the container suite as uid 0.

Not covered

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, unless allowNestedUserNamespaces or enableWeakerNestedSandbox is on.

  • The option needs bubblewrap 0.8.0 or later, not setuid. Where it cannot be used, one warning per process says that nothing keeps the command from creating a user namespace.
  • Never passed beside the helper, which makes a namespace of its own.
  • There unshare -r and a nested bwrap fail with ENOSPC ("No space left on device"), not EPERM.

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.
@ronleizrowice-ant ronleizrowice-ant mentioned this pull request Sep 21, 2026
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 antdres left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.
@ronleizrowice-ant ronleizrowice-ant changed the title linux: a sandboxed command cannot make user namespaces of its own linux: a sandboxed command stays in the namespaces made for it Oct 13, 2026
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.
@dylan-conway
dylan-conway merged commit 9a277e5 into main Oct 14, 2026
16 checks passed
@dylan-conway
dylan-conway deleted the fix/linux-no-nested-user-namespaces branch October 14, 2026 02:11
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.

3 participants