CoursesAdvanced container securityCapability & kernel escapes

Capability & kernel escapes

SYS_ADMIN, /proc, and CVE-driven breakouts.

Advanced14 min · lesson 24 of 25

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.

terminal
# 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.

terminal
# detection: add SYS_ADMIN, then read the effective set straight from /proc.
$ docker run --rm --cap-add SYS_ADMIN alpine grep CapEff /proc/self/status
CapEff: 00000000a82425fb
# a80425fb is the default; the extra 0x200000 bit (21) is SYS_ADMIN. decode it:
$ capsh --decode=00000000a82425fb | tr ',' '\n' | grep sys_admin
cap_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.

terminal
# 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/status
Seccomp: 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/status
Seccomp: 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.

terminal
# 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 -r
6.8.0-52-generic # cross-check your distro tracker (Dirty Pipe fixed in 5.16.11+)
Trace the grant to the control that actually stops it
A container was granted a privileged capability
which lock keeps the breakout from landing?
SYS_MODULE
load a kernel module
stopped by cap-drop ALL, and by default seccomp: init_module isn't on the allow-list either way
SYS_ADMIN
mount + release_agent
stopped by cap-drop ALL; default seccomp still permits mount while the cap is held
kernel CVE
Dirty Pipe / netfilter
no cap needed; stopped by a patched host plus a tight seccomp profile
cap-drop removes the key, seccomp removes the syscall, a patched kernel removes the bug. Untrusted code still wants a sandbox.

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.

The default profiles are quietly doing your cap-drop's job
CVE-2022-0492 was a cgroups v1 flaw in the release_agent path: a container that could mount a cgroup hierarchy could point release_agent at a script and have the host run it as root. Whether a given container could actually pull that off came down to how confined it was. The default seccomp and AppArmor profiles blocked the path (AppArmor is a Linux Security Module, or LSM, a kernel add-on that limits which files and operations a program can touch), so stock Docker containers were fine. Anything started with --privileged or --security-opt seccomp=unconfined was not. The trap is convenience. The afternoon someone turns seccomp off to make a stubborn image work, they hand back the exact protection that had been quietly covering for a kernel bug they never knew they had.

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.

terminal
$ 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.

Quick check
01You start a container with --cap-add SYS_MODULE and no other security flags, on a current Docker install. Can it load a kernel module and take the host?
Incorrect — The key is necessary but not sufficient. Docker's default seccomp profile doesn't list init_module or finit_module, so the loader's syscall dies before the kernel ever sees it.
Correct — You would also need seccomp=unconfined (or --privileged) before the grant becomes usable. Two independent locks, the capability and the syscall filter, and this attack needs both of them open.
Incorrect — Docker does add the capability you asked for, and CapEff in /proc will show it sitting there. Seccomp, a separate control, is what stops the syscall.
Incorrect — Backwards. Loading a module needs the capability in the effective set, which a non-root user without it never has. The blocker here is seccomp, not the user id.
02The lesson calls CAP_SYS_ADMIN the more dangerous grant of the two, even though both can end in host takeover. What makes it worse?
Incorrect — No. Neither one restores the full set. That is what --privileged does.
Correct — The stock filter leaves SYS_ADMIN's key syscall open, so only dropping the capability stops it, while SYS_MODULE gets stopped by seccomp regardless.
Incorrect — No. Docker adds the capability you asked for and CapEff in /proc will show it. Seccomp is the separate lock that blocks the load.
Incorrect — No. Adding a capability does not turn off the LSM. Both caps are held under the same profiles.
03A fleet sweep prints /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?
Incorrect — No. The privileged flag is not required. SYS_ADMIN with the filter switched off is already enough for the release_agent escape.
Incorrect — No. An empty cap_add means the safe default set of 14 caps, not the full keyring.
Correct — SYS_ADMIN supplies the mount capability and unconfined seccomp removes the second lock, which reopens the release_agent path.
Incorrect — No. Seccomp is applied per container. /web is still running under the default profile.

Related