Skip to content

Repository files navigation

sandbox

sandbox is an unprivileged, low-level sandboxing tool for Linux. It uses user namespaces so that an ordinary, unprivileged user can construct a container-like environment for a single command.

It works by creating a new, completely empty mount namespace where the root is on a tmpfs that is invisible from the host, and which is automatically cleaned up when the last process exits. You then use command-line options to build up the root filesystem, the process environment, and the command to run inside the namespace.

Beyond namespaces it can apply a Landlock ruleset, a built-in seccomp filter, resource limits and an AppArmor transition — restrictions that travel with the process rather than with its view of the filesystem. Those need no external toolchain: there is no libseccomp dependency and no policy compiler.

It is a toolkit for constructing sandboxes, not a ready-made sandbox with a security policy of its own. The level of isolation between the sandboxed process and the host is entirely determined by the arguments you pass. Whatever constructs those arguments — a wrapper script, a configuration file, or a larger framework — is responsible for defining the security model. See SECURITY.md.

Building

Linux, and Rust 1.88 or later. Edition 2024 alone would only need 1.85; the floor is let-chains. CI builds on exactly 1.88, so this is a checked number rather than a guess.

There is no minimum kernel version, deliberately. The base sandbox needs nothing newer than Linux 3.19; each hardening control asks the running kernel what it can do and refuses loudly if the answer is not enough — --close-fds wants 5.9, Landlock 5.13, and --unshare cgroup 4.6. The man page has the table.

cargo install --locked --git https://github.com/necessary-nu/sandbox

or, from a checkout:

cargo install --locked --path .

Either puts sandbox in ~/.cargo/bin. --locked builds against the committed Cargo.lock rather than resolving fresh versions, so you get the dependency set this was tested with; that matters more here than for most programs, since the ones doing the work are rustix and libc.

To build without installing, cargo build --release writes target/release/sandbox, which is self-contained and can be copied anywhere on your PATH.

There is no crates.io release, and cargo install sandbox is not this: that name there belongs to an unrelated crate.

There is no setuid mode either — the binary refuses to run if it has been made setuid — so the host needs unprivileged user namespaces enabled, or you need to be root.

Usage

sandbox [OPTION...] [--] COMMAND [ARG...]

A small example that runs a shell command in a namespace reusing the host's /usr, with fresh /proc and /dev, no network, and a detached terminal session:

sandbox \
    --bind /usr:/usr:ro \
    --symlink usr/lib:/lib \
    --symlink usr/lib64:/lib64 \
    --symlink usr/bin:/bin \
    --proc /proc \
    --dev /dev \
    --unshare all \
    --new-session \
    --die-with-parent \
    /bin/echo "hello from the sandbox"

Note that the symlinks above are what a merged-/usr host expects; on a different layout you will need different ones. They are also not optional decoration: without them the dynamic loader is missing, and execve fails with ENOENT naming the program rather than the interpreter it could not find. There is a fuller demo script that sets up /tmp, /var, stub passwd/group files and an interactive shell.

The full option reference is in docs/sandbox.1.md, and sandbox --help lists every option.

Shell completions are generated by the binary itself, so they cannot fall out of step with the options it accepts:

sandbox --completions bash > /usr/share/bash-completion/completions/sandbox
sandbox --completions zsh  > /usr/share/zsh/site-functions/_sandbox

fish, elvish and powershell work the same way. There is no automatic installation.

Restrictions that travel with the process

Namespaces change what the sandboxed process can see. The controls below change what it can do, and they survive anything it mounts, creates or re-enters. All of them are refused loudly rather than skipped quietly: a control the caller asked for and the kernel cannot apply is fatal, because these have no observable effect when they work, so a sandbox silently missing one is a sandbox you will trust anyway. --not-a-security-boundary downgrades that to a warning, and the name is the whole documentation.

Landlock

Deny-by-default filesystem and TCP access, enforced by the kernel and independent of the mount namespace.

sandbox --bind /:/:dev \
    --landlock /usr:read:exec \
    --landlock /etc:read \
    --landlock /tmp:read:write \
    --landlock /dev:read:write \
    --landlock-tcp 443:connect \
    -- /bin/sh

Rights are read, write, exec and dev for paths, and bind/connect for ports. Naming one path restricts every other: the whole root is bound above and only these four are reachable, so /etc is readable but not writable and /root is not there at all. Grant what the program needs and nothing else; that is the point of it.

A right may only be granted on a path it applies to. read means "read files and list directories", and Landlock rejects a directory right on something that is not one — so --landlock /etc/passwd:read is refused, and the rule belongs on the directory.

There is one ruleset, not two, so naming only ports still denies the filesystem: a --landlock-tcp with no --landlock fails at execve, because nothing granted the right to execute anything.

The ABI is negotiated at run time, and rights the running kernel is too old to enforce are reported rather than dropped. The ruleset is applied at the last moment before execve, after the mount namespace is built and capabilities are gone.

Seccomp profiles

Built-in filters, composable with each other and with any program supplied through --seccomp FD:

sandbox --bind /:/ --seccomp-profile default -- /bin/sh
  • default denies ptrace, bpf, keyctl, io_uring and TTY injection — the routes out of a namespace sandbox.
  • root-emulation tells a package manager its chown, chmod and mknod succeeded, so an unprivileged build finishes instead of failing on ownership it was never going to be granted. Useful for building distribution packages without a privileged container; not a security control.

Filters are assembled as classic BPF in-tree, so there is no libseccomp dependency. Every filter begins with an architecture check, without which a syscall-number filter is not a filter at all.

Resource limits

sandbox --bind /:/ --limit nproc=64,nofile=1024,as=2G,core=0 -- /bin/sh

Names are the ones ulimit and /proc/self/limits use. A limit is never raised above what the sandbox inherited, only lowered. Where a delegated cgroup v2 subtree is available the process count is enforced with pids.max rather than RLIMIT_NPROC, which is counted per-uid and so does not hold a sandbox that maps more than one uid; without one, RLIMIT_NPROC is used and the difference is reported.

AppArmor

sandbox --bind /:/ --apparmor-profile sandbox -- /bin/sh

Transitions the command into an already-loaded profile on execve. The profile must exist; the sandbox will not load one. apparmor/sandbox here grants user namespace creation and nothing further, which is what recent Ubuntu kernels require before an unprivileged sandbox can start at all — and when one is refused for that reason, the diagnostic says so rather than leaving you to guess.

Describing a sandbox in a file

A command line long enough to be worth reviewing is one that should not be a command line:

sandbox --config sandbox.toml -- /bin/sh
[sandbox]
unshare = ["user", "pid", "ipc", "uts"]
hostname = "example"
seccomp-profiles = ["default"]

[sandbox.limits]
nproc = 64
as = "1G"

[sandbox.landlock]
"/usr" = ["read", "exec"]
"/tmp" = ["read", "write"]

[[op]]
kind = "bind"
src = "/usr"
dst = "/usr"
opts = ["ro"]

[[op]]
kind = "tmpfs"
dst = "/tmp"

The document takes effect where --config appears, exactly as though it had been written there, so there is no precedence to learn: options compose in the order they appear, and a file is just a long option. Operations are one tagged array because their order is part of what they mean.

A document is read into the same Config the Rust API builds, which produces the arguments the ordinary parser reads — so it cannot express anything the command line cannot, or mean anything different by it. It may not name a file descriptor, which means something different on every invocation, nor the command to run, so that handing someone a configuration file is not the same as handing them a program.

examples/sandbox.toml is a commented document showing every key.

As a library

The crate is usable from Rust directly. No sandbox executable is involved: spawn forks, and the child does everything the binary does, using the same parser, so there is one implementation of what the options mean and nothing to keep in step between a library and a separate program.

use sandbox::config::{Config, Namespaces, Rights, Tmpfs};
use sandbox::limits::Limit;
use sandbox::seccomp_profile::Profile;

let cfg = Config::new()
    .unshare(Namespaces::all() - Namespaces::NET)
    .bind_ro("/usr", "/usr")
    .symlink("usr/bin", "/bin")
    .symlink("usr/lib", "/lib")
    .symlink("usr/lib64", "/lib64")
    .tmpfs(Tmpfs::new("/tmp").perms(0o700))
    .seccomp_profile(Profile::Default)
    .limit(Limit::Processes, Some(64))
    .landlock("/usr", Rights::READ | Rights::EXEC)
    .landlock("/tmp", Rights::READ | Rights::WRITE)
    .command(["/bin/sh", "-c", "echo hello from the sandbox"]);

// SAFETY: this process is single-threaded; see below.
let sandbox = unsafe { cfg.spawn() }?;
let status = sandbox.wait()?;

That is examples/readme_library.rs verbatim, so it is compiled and run by the build rather than being prose that once worked: cargo run --example readme_library.

Every control the command line has is reachable here. That direction matters as much as the other one: a Config that could not express an option would leave it unreachable from the library and from every configuration file, and unreachable in the way that is hardest to notice, since the sandbox would still start and still report success.

Nothing about your process changes. Building a sandbox takes over the process that does it — it becomes the monitor and reaper for everything inside, blocks until the sandboxed program exits, blocks SIGCHLD, reaps every zombie it can find, and latches PR_SET_NO_NEW_PRIVS, which cannot be undone. The fork is what keeps all of that off you, and it makes the reaping a non-issue for free: a freshly forked child has no children of its own, so there is nothing of yours for it to consume.

spawn is unsafe for one reason. Between the fork and the sandboxed program's execve the child allocates, which is only sound if the forking process is single-threaded — otherwise another thread may hold the allocator's lock at the moment of the fork. This is the same hazard as any pre_exec closure that does real work, and only the caller can know whether it applies.

Ordering is preserved, because it is significant: filesystem operations apply in the order given, and so do environment operations, where clear_env after a setenv discards it and before one keeps it.

There is also Config::to_command(path), which returns a std::process::Command that invokes a separately installed sandbox executable. That is for driving an installed binary, not for embedding: it serialises to a command line, so it is subject to ARG_MAX, visible in ps, and coupled to that binary's option syntax. Prefer spawn.

Tests

cargo test

No flags. The tests need unprivileged user namespaces to be available on the host, and nothing else.

One target is unusual and the reason is worth knowing if you add to it. Config::spawn requires a single-threaded caller, and a libtest harness is a thread pool — so a #[test] calling it breaks the contract however it is written, and serialising with a mutex would not help, since the other threads still exist. tests/spawn.rs therefore sets harness = false and runs those four tests from its own main. The precondition holds because of how the binary is built rather than because of how it is invoked, which is why the rest of the suite can run in parallel and why there is no --test-threads=1 to remember.

Limitations

The level of protection is determined by the arguments, so some things need particular care:

  • TTY injection through TIOCSTI is blocked by --seccomp-profile default, and --new-session prevents it as well by detaching the terminal (see CVE-2017-5226). With neither, a sandboxed process can push characters into the terminal it inherited and have your shell run them.

  • Everything mounted into the sandbox can potentially be used to escalate privileges. For example, if you bind a D-Bus socket into the sandbox, it can be used to execute commands via systemd. A Landlock ruleset limits what a process can reach regardless of what was mounted, which makes it a useful second answer here, but it does not make a bound socket safe.

  • Some applications deploy their own sandboxing mechanisms, and those can be broken by the constraints imposed here. For example, a web browser that configures its child processes via seccomp cannot do so if the seccomp syscall is filtered out, or if the rules it wants to load live in a file that is not visible inside the sandbox.

Licence and attribution

This project began as a Rust port of bubblewrap by Alexander Larsson, Colin Walters and the containers project, and is distributed under the same terms: LGPL-2.0-or-later. See LICENSE for the full text.

It has since diverged: the command line was reorganised, compatibility with upstream is not maintained, and the controls described above have no counterpart there.

Bubblewrap in turn inherited code from xdg-app-helper, which distantly derives from linux-user-chroot.

About

No description, website, or topics provided.

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages