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:

ToolWhat it confines
seccomp-bpfwhich system calls the process may make at all — the biggest lever on kernel attack surface
capabilitieswhich slivers of root's power it holds (drop nearly all)
namespaceswhat it can see — its own pid/mount/net/user view
chroot / pivot_rootwhich part of the filesystem tree it can reach
rlimits / cgroupshow 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.

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.