Capability & kernel escapes
SYS_ADMIN, /proc, and CVE-driven breakouts.
Root inside a container and root on the host sound like the same thing. They are not, and capabilities are the reason. Linux took the master key that root used to carry and cut it into roughly 40 separate keys, one per privileged operation. Each key is a capability, a cap for short. When Docker starts a container it hands over 14 of those keys and keeps the rest to itself. That is why the root user inside a container can bind port 80 and change who owns a file, but cannot load a kernel module or re-mount the host's disks. A capability escape begins the moment someone puts one of the dangerous keys back on the ring. Almost always by accident, in a docker run line nobody read closely.
The two keys you should never hand back
Start with CAP_SYS_MODULE. It lets a process load a kernel module, and a kernel module is a chunk of code you slot into the running kernel, where it runs with the kernel's own authority and nothing above it to say no. A container holding that key can insert a module that opens a root shell on the host. No vulnerability is involved. One flag does it. CAP_SYS_ADMIN is the junk drawer of the keyring, a pile of unrelated privileged operations nobody ever bothered to split apart, and one of them is mount. Mount is what the classic cgroup (control group, the kernel feature that caps how much CPU and memory a set of processes may use) release_agent breakout abuses. You mount a cgroup hierarchy, set its release_agent, a program the host runs as root the moment the group empties out, to a script you wrote, and the host obediently runs your script. Two more caps deserve a mention. CAP_SYS_PTRACE lets one process reach into another and drive it; combine it with a shared PID (process ID) namespace, where the container sees the host's processes instead of only its own, and it can hijack one of them. CAP_DAC_READ_SEARCH was the heart of the old Shocker trick, which read any file on the host.
# baseline: the caps a stock container actually gets. note what is NOT here.$ docker run --rm alpine sh -c \'apk add -q libcap; capsh --print | grep Current'Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap=ep# no sys_admin, no sys_module, no sys_ptrace, no dac_read_search. that's the point.
You cannot defend what you cannot see, so learn to read the keyring straight off a running process. Every process publishes its capabilities in /proc/<PID>/status as four hex numbers. Each number is a bitmask, meaning one bit stands for one cap. The one that matters most is CapEff, the effective set, which says what this process is allowed to do right now. Hex is unreadable to humans, so capsh --decode turns the number back into names. That reading is your ground truth. The run command can lie to you and so can the Dockerfile. The running process cannot.
# detection: add SYS_ADMIN, then read the effective set straight from /proc.$ docker run --rm --cap-add SYS_ADMIN alpine grep CapEff /proc/self/statusCapEff: 00000000a82425fb# a80425fb is the default; the extra 0x200000 bit (21) is SYS_ADMIN. decode it:$ capsh --decode=00000000a82425fb | tr ',' '\n' | grep sys_admincap_sys_admin# a container spec you never audited can carry this. the bitmask does not lie.
Seccomp is the second lock
SYS_MODULE in a container spec looks like the end of the story. Usually it isn't, because a second lock sits behind the first and most people forget it is there. Seccomp (secure computing mode) is a filter the kernel runs on a process's behalf, and it works like a bouncer holding a guest list of system calls. A system call, or syscall, is the request a program makes when it needs the kernel to do something for it: open a file, send a packet, load a module. The bouncer checks every request against the list and turns away anything that isn't written down. Docker applies a default seccomp profile to every container it starts, and init_module, finit_module and delete_module appear nowhere on that list. So the capability can sit in the effective set looking lethal while the syscall the module loader depends on never reaches the kernel. Two independent locks, the key and the guest list. Loading a module means picking both.
# SYS_MODULE looks fatal, but the DEFAULT seccomp profile blocks the load syscalls# even when the cap is present. detection = read the seccomp mode from /proc.$ docker run --rm --cap-add SYS_MODULE alpine grep Seccomp /proc/1/statusSeccomp: 2 # 2 = filter active. init_module/finit_module aren't on the allow-# list, so the loader's syscall dies before it reaches the kernel.# the only way to make the cap usable is to throw the filter away. don't do this:$ docker run --rm --security-opt seccomp=unconfined --cap-add SYS_MODULE \alpine grep Seccomp /proc/1/statusSeccomp: 0 # 0 = disabled. now the cap is live and the host is one insmod away.
Not every dangerous syscall gets refused outright, and that difference is the whole point of this section. The default profile does allow mount, on the condition that the process already holds CAP_SYS_ADMIN. So a container handed SYS_ADMIN can still perform the mount half of a release_agent escape under the stock profile, while a container handed only SYS_MODULE walks into the filter and stops. That asymmetry is why SYS_ADMIN is the worse of the two to give away. Its key syscall is permitted, so dropping the capability is carrying almost the whole defense by itself.
Kernel bugs: the escape no flag prevents
Everything so far assumes the kernel follows its own rules. Sometimes it doesn't. Every container on a machine shares one kernel the way every flat in a building shares one set of water pipes, so a privilege-escalation bug reachable through an ordinary syscall becomes an escape route that needs no capability at all. Dirty COW (CVE-2016-5195) and Dirty Pipe (CVE-2022-0847) both let an unprivileged process write to files it was only allowed to read, and both were demonstrated overwriting host binaries from inside a container. The netfilter and io_uring subsystems have produced a steady run of similar privilege-escalation flaws. You cannot patch a kernel from inside a container. What you can do is shrink how much of that kernel the container is able to touch, which is seccomp again, because a smaller syscall surface is a smaller bug surface, and keep the host kernel current.
# fleet detection: which running containers were granted escape-grade caps, ran with# the filter off, or went privileged? docker inspect is the truth of how they started.$ docker ps -q | xargs -r docker inspect --format \'{{.Name}} cap_add={{.HostConfig.CapAdd}} seccomp={{.HostConfig.SecurityOpt}} priv={{.HostConfig.Privileged}}'/web cap_add=[] seccomp=[] priv=false/ci-runner cap_add=[SYS_ADMIN] seccomp=[seccomp=unconfined] priv=false # fix this# then confirm the host kernel is patched past the classes you care about:$ uname -r6.8.0-52-generic # cross-check your distro tracker (Dirty Pipe fixed in 5.16.11+)
Turn all of this into a check that runs whether or not you remember to. In CI, or in an admission controller (the gate that reads a workload spec before the cluster is allowed to start it), fail anything that adds SYS_ADMIN, SYS_MODULE, SYS_PTRACE or DAC_READ_SEARCH, sets seccomp to unconfined, or flips privileged on. On the hosts, track the running kernel against your distro's security tracker so a container-reachable CVE doesn't sit there for six weeks. The checks cost you an afternoon. The grants they catch cost you the host.
Notice who owns each half of this. Capability escapes come from keys your own team handed back. Kernel and runc escapes come from code nobody on your team wrote. Patching the host kernel and keeping runc (the low-level runtime that actually starts every container) current belongs to whoever owns container security, not to some other group's roadmap.
Shrink the syscall and capability surface and even a bug nobody has published yet becomes harder to reach. Sandboxes, which the next lesson covers, change the economics further.
When the next container CVE lands, you want three answers quickly: does the exploit need privileged or an added capability, which runtime versions are affected, and how fast can you drain and bounce your nodes?
In production, run the same reading after every change window. Confirm the control is still on, paste the command and its output into the ticket, and refuse to close the change if the numbers drifted. Pick the tightest scope the workload can still run under, every time. That habit compounds across every host and every pipeline you own.
Try this
On a lab box you don't mind breaking, print CapEff for a plain container, then for the same image with --cap-add SYS_MODULE and with --cap-add SYS_ADMIN. Put the numbers side by side and confirm your own baseline drops both keys.
$ docker run --rm alpine sh -c 'grep '^CapEff' /proc/1/status'CapEff: 00000000a80425fb$ docker run --rm --cap-add SYS_ADMIN alpine sh -c 'grep '^CapEff' /proc/1/status'CapEff: 00000000a82425fb$ docker run --rm --privileged alpine sh -c 'grep '^CapEff' /proc/1/status'CapEff: 0000003fffffffff$ # privileged ≈ full keyring — treat as host admin
Takeaway
Never hand SYS_ADMIN or SYS_MODULE to an ordinary workload, keep the host kernel and runc patched, and treat capability creep as the way quiet escapes usually begin.
/web cap_add=[] seccomp=[] priv=false and /ci-runner cap_add=[SYS_ADMIN] seccomp=[seccomp=unconfined] priv=false. Neither one is privileged. Which is the real escape risk, and why?