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.
| Capability | Grants the power to… | Notes |
| \texttt{CAP\_NET\_BIND\_SERVICE} | bind to ports below 1024 | the classic "just need port 80" case |
| \texttt{CAP\_NET\_ADMIN} | configure interfaces, routes, firewall | powerful — 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 modules | full kernel takeover — never in a container |
| \texttt{CAP\_SYS\_PTRACE} | trace/inspect other processes | can read others' memory |
| \texttt{CAP\_CHOWN} | change file ownership | dropped by default in Docker |
| \texttt{CAP\_DAC\_OVERRIDE} | bypass file read/write/execute checks | ignore all permission bits |
| \texttt{CAP\_KILL} | send signals to any process | bypasses 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
- Capabilities split the all-or-nothing root into ~40 independent bits
(\texttt{CAP\_NET\_ADMIN},
\texttt{CAP\_SYS\_ADMIN}, …); drop every bit the workload doesn't use.
- seccomp / seccomp-bpf filters which syscalls a process may issue,
evaluated in-kernel per call; a filter can only ever reduce the interface and is inherited
across \texttt{exec}.
- They are orthogonal, complementary defences: capabilities restrict
authority, seccomp restricts the reachable interface — together they shrink the
shared-kernel attack surface.
- Docker applies both by default: dropped capabilities plus a default seccomp
profile blocking ~40 dangerous syscalls — defence in depth over the one shared kernel.
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.