Skip to content

linux: with no seccomp helper, have bubblewrap disable user namespaces - #617

Merged
ronleizrowice-ant merged 8 commits into
fix/linux-no-nested-user-namespacesfrom
fix/linux-disable-userns-without-helper
Oct 13, 2026
Merged

ronleizrowice-ant merged 8 commits into
fix/linux-no-nested-user-namespacesfrom
fix/linux-disable-userns-without-helper

Conversation

@ronleizrowice-ant

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

Copy link
Copy Markdown
Collaborator

Stacked on #588 (base: its branch). #588 keeps a sandboxed command in the namespaces made for it through the seccomp helper. A wrap with no helper in its chain (allowAllUnixSockets: true, or no helper for the architecture) gets no limit there. So an option that is about sockets also decides whether the limit applies.

Four files, +207.

What changes

  • Such a wrap gives bubblewrap --disable-userns, unless allowNestedUserNamespaces or enableWeakerNestedSandbox is on. (Bubblewrap does it by writing a sysctl, and dies on the read-only /proc/sys of an unprivileged container.)
  • The option needs bubblewrap 0.8.0 or later, not setuid. The wrap reads bwrap --help and the file mode, once per binary path. A failed probe is not remembered.
  • Where the option 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.

Who sees a difference

With no helper, by default:

what it says
unshare -r unshare: unshare failed: No space left on device
a nested bwrap bwrap: Creating new namespace failed: nesting depth or /proc/sys/user/max_*_namespaces exceeded (ENOSPC)

With the helper they fail with EPERM, as in #588. allowNestedUserNamespaces lifts both, for the session or for a single wrap.

Testing

Merge order

After #588. #594 merges with no conflict in the source, but the two must be joined by hand: this PR's probe looks bwrap up on PATH and runs --help on the host, which #594 forbids for a copy the sandboxed command can write. Whichever lands second makes the probe ask the binary the wrap resolved.

Not here

Asking the helper what it supports, anything on checkDependencies(), new exported types.

A wrap with no seccomp helper in its chain (`allowAllUnixSockets`, or
no helper for the architecture) put no limit on user namespaces, so a
sandboxed command could create one and undo the write denies there.

Such a wrap now passes `--disable-userns` to bubblewrap, unless
`allowNestedUserNamespaces` or `enableWeakerNestedSandbox` is on. The
option needs bubblewrap 0.8.0 or later and a binary that is not setuid:
the wrap reads `bwrap --help` and the file mode once per path, and does
not remember a probe that failed. Where the option cannot be used, one
warning per process says so. It is never passed beside the helper,
which makes a namespace of its own.

@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.

No blockers from me on the diff over #588. With allowAllUnixSockets: true (no seccomp helper in the chain) the write-deny escape still works on #588 and is refused here: unshare -Ur true fails with ENOSPC and the protected .git/config is untouched, as a non-root user and as root. With a setuid bwrap, or one whose --help has no --disable-userns, the wrap still runs and the flag is left out, so those hosts stay as exposed as before; the only signal is a debug-level log line. The new test fails against #588's source.


Generated by Claude Code

@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.

Approving at 9711be4. No blocking issues; what I ran is in my comment above.


Generated by Claude Code

@ronleizrowice-ant
ronleizrowice-ant merged commit 75b39fe into fix/linux-no-nested-user-namespaces Oct 13, 2026
16 checks passed
@ronleizrowice-ant
ronleizrowice-ant deleted the fix/linux-disable-userns-without-helper branch October 13, 2026 22:23
dylan-conway pushed a commit that referenced this pull request Oct 14, 2026
* seccomp helper: keep the command from making namespaces of its own

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.

* linux: a sandboxed command may not create user namespaces unless the 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.

* test: the command stays in the namespaces made for 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.

* linux: the dependency check and the wrap decide the namespace limit in 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.

* seccomp helper: non-dumpable before its first fork, and the outer half 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.

* linux: ask the helper safely, pin its variables on its own command line, 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.

* seccomp helper: build with a libseccomp that cannot name the two newest 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.

* seccomp helper: never emit a filter that quietly lacks a call

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.

* seccomp generator: fail on a call it cannot name, and test the generator

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.

* linux: ask the seccomp helper what it supports inside bubblewrap

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.

* test: pin what nothing pinned in the limit on user namespaces

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.

* Shorten the comments this change adds

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.

* Cut this change down to its core

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.

* test: keep the cases that pin the core of the namespace limit

* linux: nor a mount namespace of its own

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.

* allowNestedUserNamespaces can be given for a single wrap

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.

* README: the filter refuses a mount namespace too

* linux: with no seccomp helper, have bubblewrap disable user namespaces (#617)

Squash merge of 8 commits
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