Capabilities and seccomp: why container root is a declawed root
Container root is not host root. Capabilities split root's power into ~40 flags — Docker keeps ~14 and drops the rest. Seccomp filters the syscall table, blocking ~44 of 350+ by default. --privileged throws both away, and that is how escapes happen.
The Dockerfile had --privileged in the run command, and the comment beside it said # needed for the health check to ping. Someone, years ago, hit “operation not permitted” calling ping, googled it, found a Stack Overflow answer that said add --privileged, and it worked. What it actually needed was one capability: CAP_NET_RAW, to open a raw ICMP socket. What --privileged gave it was everything — every capability, the host’s full device tree at /dev, no seccomp filter, write access to /sys. A penetration test later proved the cost: from inside that container, a tester mounted the host’s root filesystem (CAP_SYS_ADMIN + visible block devices) and wrote a cron job onto the node. One missing capability had been “fixed” by removing every barrier the container had. The two-line correct fix — drop all capabilities, add back CAP_NET_RAW — gave ping exactly what it needed and the attacker nothing.
Capabilities: root, divided
Here’s the question that exposes the most common container security mistake: if your container runs as root and hits EPERM, what do you do? The wrong answer — --privileged — is how escapes happen. The right answer requires knowing which capability is missing and granting only that.
Traditional Unix had a binary: UID 0 could do anything, everyone else was checked. Linux capabilities split that monolithic root power into ~40 distinct privileges, each independently grantable. A few that matter:
- CAP_SYS_ADMIN — the “new root”, a grab-bag including
mount, namespace creation, and dozens of operations; the most dangerous to grant. - CAP_NET_RAW — open raw and packet sockets (what
pingactually needs). - CAP_NET_BIND_SERVICE — bind to ports below 1024.
- CAP_SYS_PTRACE — trace/attach to other processes (a debugger; also a way to read another process’s memory).
- CAP_DAC_OVERRIDE — bypass file read/write/execute permission checks.
- CAP_SYS_MODULE — load kernel modules (a direct path to owning the kernel).
Notice how narrow each one is: CAP_NET_RAW gives raw sockets and nothing else; CAP_NET_BIND_SERVICE covers only port binding below 1024. That narrowness is the whole point — when you grant exactly the capability a service needs, you give an attacker precisely nothing extra. Compare that with CAP_SYS_ADMIN, which unlocks mounting, namespace creation, and dozens of other operations: granting it for any single need exposes the rest.
When a container runs as “root” (UID 0), it does not get all of these. Docker starts a container with a default allow-list of about 14 capabilities and drops the rest — so a container-root process that calls mount gets EPERM, because CAP_SYS_ADMIN is not in the set. This is why “container root” is a declawed root: it has UID 0’s identity but a fraction of its kernel powers. The right posture for a hardened service is --cap-drop=ALL and then --cap-add only the one or two it provably needs — least privilege made concrete.
# Default-dropped (NOT granted) by Docker — among others:
CAP_SYS_ADMIN CAP_SYS_MODULE CAP_SYS_PTRACE CAP_SYS_TIME
CAP_NET_ADMIN CAP_SYS_RAWIO CAP_DAC_READ_SEARCH CAP_SYSLOG ...
# Hardened service:
docker run --cap-drop=ALL --cap-add=CAP_NET_BIND_SERVICE myappSeccomp: filtering the syscall table
Capabilities gate privileged operations; seccomp (secure computing) filters the syscall interface itself — a BPF program the kernel runs on every syscall, deciding allow / errno / kill. Docker ships a default seccomp profile that blocks roughly 44 of the 350+ syscalls on x86-64, chosen because they are dangerous and rarely needed in containers: keyctl (kernel keyring, container-breakout history), ptrace (until relaxed), mount/umount2, reboot, swapon, kexec_load, bpf, clock_settime, init_module, and others. The filter is defense-in-depth that complements capabilities: even a process holding CAP_SYS_ADMIN is still blocked from mount if the seccomp profile denies that syscall. The default profile is tuned so the vast majority of real workloads never notice it — and the syscalls it blocks are exactly the ones a container escape reaches for.
A container running as root calls mount() and gets EPERM, even though it is UID 0. Why is container root unable to mount, and what is the single cleanest reason?
▸Why this works
Why two mechanisms instead of one? Capabilities answer “is this process allowed to perform this privileged operation?” and seccomp answers “is this process allowed to make this syscall at all?” — different layers. A capability can be too coarse: CAP_SYS_ADMIN unlocks dozens of operations, so granting it for one need exposes the rest, and that is where seccomp earns its place, narrowing the syscall surface even when a broad capability is present. Together with the user namespace (remapping root) and cgroups (limiting resources), they are independent layers: defeating one — say, a kernel bug that bypasses seccomp — still leaves the others standing. --privileged is dangerous precisely because it collapses several layers at once.
—privileged: the off switch for all of it
--privileged is not “a bit more access” — it is the simultaneous removal of nearly every container security boundary. It grants all capabilities, disables the seccomp filter, exposes all host devices under /dev, and gives write access to /sys and parts of /proc. With all capabilities (CAP_SYS_ADMIN) and host block devices visible, a process can mount the host’s root filesystem and write to it — the cron-job escape from the Hook. The legitimate uses are narrow (Docker-in-Docker, some device-driver and storage workloads), and even those usually want a specific capability and device, not the blanket switch. The reflex to reach for --privileged when something returns EPERM is the single most common way a container becomes a host-takeover vector. The disciplined move is to read which capability or syscall was denied and grant exactly that.
A team adds --privileged because ping returned 'operation not permitted'. A pen-tester then mounts the host's root filesystem from inside the container. What did --privileged change that enabled the host filesystem mount?
- 01Explain how capabilities and seccomp are two different layers, with a concrete example where both are involved in denying mount().
- 02Describe exactly what --privileged removes and why an EPERM 'fixed' with it is dangerous, using the ping example.
Running as root inside a container is not the same as being root on the host, and two kernel mechanisms enforce that. Capabilities break the old all-or-nothing UID-0 power into about 40 independently-grantable privileges — CAP_SYS_ADMIN (mount and much more), CAP_NET_RAW (raw sockets for ping), CAP_SYS_PTRACE, CAP_SYS_MODULE, and the rest. Docker grants a container only a default set of roughly 14 and drops the others, so container root that calls mount or tries to load a module gets EPERM; the hardened posture is —cap-drop=ALL plus —cap-add of only the one or two a service provably needs. Seccomp is the second, orthogonal layer: a BPF filter on the syscall interface, and Docker’s default profile blocks about 44 of the 350-plus syscalls — keyctl, ptrace, mount, reboot, kexec_load, init_module and other escape-grade calls — while leaving normal workloads untouched. The two complement each other, and together with the user namespace and cgroups they form independent layers, so breaking one leaves the rest standing. —privileged is the anti-pattern: it grants every capability, turns off seccomp, exposes all host devices, and opens /sys for writing — collapsing several boundaries at once. That is why a container that hit a single EPERM (needing just CAP_NET_RAW for ping) and was “fixed” with —privileged became a path for a tester to mount the host root filesystem and plant a cron job. Read the denied capability or syscall and grant exactly that; never trade every barrier for one missing permission.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.