Sandboxing and Confinement
Every day your computer runs code you have every reason to distrust: a web page's JavaScript, a PDF's
embedded fonts, a downloaded game, an image decoder fed a hostile file. You cannot avoid running it — that
is the whole point of a browser — so the question is not whether to run untrusted code but how to
run it in a box small enough that even if it is fully compromised, it can touch almost
nothing. That box is a sandbox, and building good ones is one of the hardest, most
important jobs in modern systems security.
The strategy is defence in depth: assume any single wall can be breached, so stack many.
A sandbox is not one feature but a careful composition of the confinement primitives you have already met.
The confinement toolbox
A sandbox is assembled from the kernel's isolation primitives, each shrinking a different dimension of
what the confined process can do:
| Tool | What it confines |
| seccomp-bpf | which system calls the process may make at all — the biggest lever on kernel attack surface |
| capabilities | which slivers of root's power it holds (drop nearly all) |
| namespaces | what it can see — its own pid/mount/net/user view |
| chroot / pivot_root | which part of the filesystem tree it can reach |
| rlimits / cgroups | how much CPU, memory, and how many files/processes it may consume |
The single most powerful of these is seccomp. Every kernel bug lives behind some system
call; a process that is only allowed to call \texttt{read},
\texttt{write}, \texttt{exit} and a handful of others
simply cannot reach the vulnerable code behind the hundreds of calls it has forbidden. You reduce the
kernel
attack surface to the few doors you actually need.
// A seccomp-style syscall allow-list: the sandbox permits only what the task needs; everything
// else is killed. Even a fully-compromised worker can't reach the kernel behind a blocked call.
const ALLOW = new Set(["read", "write", "exit", "sigreturn", "brk"]);
function sandboxedSyscall(name: string): string {
if (ALLOW.has(name)) return `${name}: allowed`;
return `${name}: BLOCKED (SIGKILL) — attack surface removed`;
}
for (const s of ["read", "write", "open", "socket", "ptrace", "execve", "exit"]) {
console.log(sandboxedSyscall(s));
}
console.log(`allow-list size: ${ALLOW.size} of ~400 Linux syscalls -> ~99% of the kernel is unreachable`);
Chrome: the browser as the hardest sandbox on Earth
The canonical production sandbox is a web browser, and Chrome's is the reference design. Its core move is
multi-process architecture: the untrusted work — parsing HTML, running a site's
JavaScript, decoding its images — happens in a low-privilege renderer process, one per
site, that is heavily confined (seccomp filter, no filesystem, no network of its own, dropped
capabilities, its own namespaces). The renderer can talk only to a trusted
browser process over a narrow IPC channel, and the browser process brokers every
sensitive action.
So when an attacker pops the renderer with a JavaScript-engine exploit — which happens — they land inside
a box that can barely make a system call, cannot open a file, and cannot see other tabs. To do real
damage they need a second exploit to escape the sandbox itself. Requiring a full exploit
chain instead of a single bug is what defence in depth buys you.
- a sandbox confines untrusted code so a full compromise still touches almost
nothing — built by composing seccomp, capabilities, namespaces, chroot, and
rlimits/cgroups;
- seccomp is the strongest single lever: restrict the allowed syscalls and
most of the kernel becomes unreachable;
- defence in depth — stack independent barriers so breaching one is not enough;
- real sandboxes (Chrome's per-site renderers, OpenBSD \texttt{pledge}/\texttt{unveil})
force an attacker to chain multiple exploits — one to compromise, another to escape.
Designing for confinement: pledge and unveil
Adding a sandbox to existing software is painful — you must discover exactly which syscalls and files it
needs. OpenBSD's \texttt{pledge} and \texttt{unveil}
make it almost pleasant: a program voluntarily declares "from here on I only need the
stdio and rpath promises" (\texttt{pledge}) and "I only need
to see \texttt{/etc/hosts}" (\texttt{unveil}). After
the declaration, any attempt to do more kills the process. Because a program self-confines after
its setup phase (when it still needs broad access) but before it touches untrusted input, the
dangerous window is tiny. It is the principle of least privilege made into two easy calls.
Newcomers reach for \texttt{chroot} as a jail — "change the root directory and
the process is trapped." It isn't. A process that still has the \texttt{CAP\_SYS\_ADMIN}
capability (or is root) can simply \texttt{chroot} again from a
directory whose file descriptor it kept open, and walk right out — the classic chroot escape. Worse,
chroot confines only the filesystem view; it does nothing about the network, other processes, or
the syscall interface. This is exactly why real sandboxes never rely on any single primitive: chroot is
one thin wall in a stack that also drops capabilities, applies seccomp, and enters new namespaces.
\texttt{pivot\_root} inside a fresh mount+user namespace, with capabilities
dropped, is the modern, escape-resistant replacement.
The mental model to avoid is "sandboxed = safe." A sandbox does not stop the untrusted code from being
exploited — the renderer will get popped — it limits what the attacker can do next. The
security value is entirely in the size of the box: a sandbox that still allows
\texttt{execve}, filesystem access, or a broad syscall set is theatre. And
sandboxes have their own bugs: a flaw in the kernel's namespace or seccomp implementation, or in the
broker that services the sandbox's IPC requests, becomes the escape hatch. That is why the design goal is
always to make the attacker need a chain — and why the trusted broker on the other side of the
sandbox must itself be tiny and paranoid.