Capabilities and seccomp

The last few lessons kept circling one uncomfortable fact: a container shares the host kernel, so the kernel's syscall interface is the container's attack surface. If a process inside a container runs as \texttt{root} and can call any of the kernel's ~400 system calls, then a single kernel bug reachable from those calls is a potential escape. Two Linux mechanisms exist to shrink that surface, and every serious container runtime uses both: capabilities (split root's power into pieces and drop the pieces you don't need) and seccomp (filter which syscalls a process may make at all).

The through-line is the principle of least privilege: a process should hold only the powers it actually uses. Traditional Unix violated this spectacularly — you were either \texttt{root} (all powers) or not (none). Capabilities and seccomp are the two tools that break that all-or-nothing, from two different angles: capabilities carve up authority, seccomp restricts the interface.

Capabilities — root, unbundled

Classic Unix has one magic user: UID 0, root, for whom the kernel skips almost every permission check. That is a disaster for least privilege — a program that merely needs to bind to port 80 (a "privileged" port) traditionally had to run as full root, gaining the power to also reboot the machine, load kernel modules, and read every file. Linux capabilities shatter that monolithic root into roughly 40 independent bits, each granting one slice of privilege. You grant a process only the slices it needs.

CapabilityGrants the power to…Notes
\texttt{CAP\_NET\_BIND\_SERVICE}bind to ports below 1024the classic "just need port 80" case
\texttt{CAP\_NET\_ADMIN}configure interfaces, routes, firewallpowerful — networking control
\texttt{CAP\_SYS\_ADMIN}mount, pivot_root, and a huge grab-bag"the new root" — avoid granting it
\texttt{CAP\_SYS\_MODULE}load/unload kernel modulesfull kernel takeover — never in a container
\texttt{CAP\_SYS\_PTRACE}trace/inspect other processescan read others' memory
\texttt{CAP\_CHOWN}change file ownershipdropped by default in Docker
\texttt{CAP\_DAC\_OVERRIDE}bypass file read/write/execute checksignore all permission bits
\texttt{CAP\_KILL}send signals to any processbypasses the UID-match rule

Docker's default is to run the container as root but drop all but ~14 of the capabilities, keeping a conservative set (like \texttt{CAP\_CHOWN}, \texttt{CAP\_NET\_BIND\_SERVICE}, \texttt{CAP\_KILL}) and dropping the dangerous ones (\texttt{CAP\_SYS\_ADMIN}, \texttt{CAP\_SYS\_MODULE}, …). The best practice is \texttt{--cap-drop=ALL} then \texttt{--cap-add} only what the workload proves it needs.

seccomp — filtering the syscall interface

Capabilities restrict authority, but every syscall the process can name is still reachable code in the kernel — including obscure ones the app will never use but that might harbour a bug. seccomp (secure computing) attacks this directly: it lets a process install a filter that the kernel consults on every system call, deciding whether to allow it, deny it (with an error or a fatal \texttt{SIGSYS}), log it, or trap it. The modern form, seccomp-bpf, expresses the filter as a small BPF program over the syscall number and its arguments — evaluated in-kernel, cheaply, on the syscall fast path.

Once installed, a filter is irrevocable and inherited across \texttt{fork}/\texttt{exec}, so a process can only ever reduce its own reachable interface — it can never grant itself back a syscall. Docker ships a default seccomp profile that allows the ~300 syscalls normal programs use and blocks around 40 dangerous or rarely-needed ones (\texttt{keyctl}, \texttt{ptrace} in some configs, \texttt{mount}, \texttt{reboot}, \texttt{kexec\_load}, …). That single default profile has historically blocked real kernel exploits whose trigger syscall simply wasn't on the list.

Read the two gates as defence in depth. A syscall must pass both: seccomp asks "is this syscall even permitted?" and, if so, the capability check asks "does this process hold the right to do it?" Drop the capability and block the syscall and you have closed the door twice.

A seccomp filter, in slow motion

The simulation models a tiny allow-list seccomp profile: a set of permitted syscalls, everything else denied. Watch a normal workload sail through and a suspicious one — trying to \texttt{mount} a filesystem or \texttt{reboot} the host — get stopped at the gate before the kernel ever sees it.

// A toy seccomp-bpf allow-list. Real filters are BPF over syscall number + args; this just // narrates the allow/deny decision on the syscall fast path. const ALLOW = new Set(["read", "write", "openat", "close", "mmap", "futex", "clock_gettime", "exit_group"]); const DENY_ACTION: Record<string, string> = { mount: "SIGSYS (kill)", reboot: "EPERM", kexec_load: "SIGSYS (kill)", ptrace: "EPERM", keyctl: "EPERM", }; function seccomp(syscall: string): boolean { if (ALLOW.has(syscall)) { console.log(` ${syscall.padEnd(14)} → ALLOW (passes to kernel)`); return true; } const action = DENY_ACTION[syscall] ?? "EPERM"; console.log(` ${syscall.padEnd(14)} → DENY (${action}) — kernel never runs it`); return false; } console.log("Normal web-server workload:"); ["openat", "read", "write", "close", "futex"].forEach((s) => seccomp(s)); console.log("\nCompromised process probing for an escape:"); ["ptrace", "mount", "kexec_load", "reboot"].forEach((s) => seccomp(s)); const dangerous = ["ptrace", "mount", "kexec_load", "reboot"]; const blocked = dangerous.filter((s) => !ALLOW.has(s)).length; console.log(`\n${blocked}/${dangerous.length} escape attempts blocked at the seccomp gate — before touching kernel code.`);

The design law

The scary phrase "the container runs as root" is much less scary once you know that Docker's root is a declawed root. The most dangerous single capability is \texttt{CAP\_SYS\_ADMIN} — so sprawling that kernel developers joke it is "the new root": it enables mounting filesystems, manipulating namespaces, and dozens of other operations that are stepping-stones to escape. Docker drops it by default. Combined with a \texttt{user} namespace mapping container-root to an unprivileged host UID and the default seccomp profile blocking \texttt{mount} outright, a would-be escapee finds that the powers it would reach for are simply absent — not forbidden by a policy it might argue with, but not present in the process at all. Least privilege turns "root" from a master key into a key that opens almost nothing.

A common conflation: "I dropped \texttt{CAP\_SYS\_ADMIN}, so \texttt{mount} is blocked — seccomp is redundant." Not quite. Capabilities and seccomp gate different things. A capability check asks "does this process have the authority for this privileged operation?" — the \texttt{mount} syscall still runs, walks into the kernel, and returns \texttt{EPERM}. seccomp asks "is this syscall permitted to be attempted at all?" — a blocked \texttt{mount} never enters the kernel's mount code, so a bug in that code path is never reached. That difference matters for kernel-bug defence: many exploits don't need a capability, they need to reach vulnerable kernel code with crafted arguments. Only seccomp keeps them out of that code. Use both; they are not substitutes.