The escape mindset

From one popped container to the whole host.

Advanced12 min · lesson 22 of 25

In January 2024 a team at Snyk published a set of bugs they called Leaky Vessels. They sat in runc, the small program Docker calls to actually start a container. One of them, CVE-2024-21626 (a CVE is the public catalog number the industry gives a known security bug), let a container point its working directory at a file descriptor (an open handle to a file, the number a program uses to refer to something it already opened) that runc had accidentally left pointing at the host's own filesystem. Code inside the container could then read and write host files as root. No exotic kernel exploit. A door handle left in the lock. That is what a container escape is: the moment code that was supposed to stay inside a container gets control of the machine underneath it.

A container is a process wearing restrictions

Strip away the mystique and a container is an ordinary Linux program running on the machine, no different in kind from your text editor. What turns it into a container is a bundle of restrictions the kernel (the core of the operating system, the part that owns the hardware) wraps around it. Namespaces are one-way mirrors: they change what the program can see, not what exists, so it sees only its own process IDs (PID = process ID, the number the system uses to name a running program), its own disk layout, its own network, and it concludes that small view is the whole machine. cgroups (control groups) are the electricity meter, capping how much CPU and memory it can draw. Capabilities take root's old master key and cut it into roughly 40 separate keys, so a program can hold CAP_NET_BIND_SERVICE, the key for listening on low port numbers, without holding the key that mounts disks. seccomp (secure computing mode) is the bouncer with a guest list of allowed system calls (a syscall is a request a program makes of the kernel, like 'open this file'), and anything off the list gets turned away at the door. An LSM (Linux Security Module) such as AppArmor or SELinux is a second rulebook stapled on top. An escape is what happens when one of those restrictions is missing, loosened, or switched off.

The attacker's goal never changes. Get off the container and onto the host, because the host is where the other tenants live, where the secrets sit, and where the orchestrator's credentials are cached. So a fresh foothold starts by taking inventory. Am I root in here? Which capabilities do I actually hold? Is anything from the host mounted somewhere I can reach? Here is the part that works in your favour: that inventory is also your audit. Run the same commands yourself, and every 'yes' the attacker would have celebrated becomes a finding you can go and close.

terminal
# same recon an attacker runs on landing: run it yourself to size the blast radius
$ docker exec web sh -c 'id; grep CapEff /proc/self/status; \
ls /var/run/docker.sock 2>/dev/null; mount | grep "on /host"'
uid=0(root) gid=0(root) groups=0(root)
CapEff: 00000000a80425fb
/var/run/docker.sock
/dev/sda1 on /host type ext4 (rw,relatime)

Read that output the way an attacker would. uid=0 (UID = user ID) means you are root inside the container, and unless a user namespace is remapping identities, that is the very same root the host trusts. CapEff is the effective capability set printed as a hex bitmask, a compact number where each bit stands for one of those keys. Feed it to capsh --decode=00000000a80425fb and you get the fourteen capabilities Docker hands out by default, spelled out in English. The docker.sock line is a host takeover sitting one command away. And /host is the machine's real disk, mounted read-write. Any single one of these ends the game. None of them is a kernel exploit. Every one of them is something a person typed into a docker run or a compose file and then forgot about.

Every escape needs a door already open

Here is what a defender can lean on. An escape cannot pick a container at random. Each class of escape needs one specific thing to already be true, and when that thing is not true, the whole class is off the table. A burglar who works through unlocked windows is stopped by a locked window, every time. So you can walk the preconditions like a checklist and say something precise about what a given container could and could not do if someone popped it tomorrow. That is the escape mindset. Not memorising exploits. Knowing which door each one needs open.

Escape prerequisites: which door is open?
A container got popped. Can the attacker reach the host?
walk the prerequisites in order
--privileged set?
seccomp + AppArmor off, every cap restored, host /dev exposed
fix: never set it; grant one --cap-add or --device
docker.sock mounted?
ask the daemon to start a privileged container on the host
fix: don't mount it; front it with a socket proxy
host path mounted rw?
write SSH keys, a cron job, or a payload onto the host
fix: narrow paths, read-only only
SYS_ADMIN / SYS_MODULE / SYS_PTRACE held?
a direct kernel primitive, no bug required
fix: --cap-drop ALL
none of the above?
attacker now needs a real kernel 0-day
defense: seccomp on + patched host + sandbox
Close the top four doors with configuration and the attacker is left with the expensive path, a live kernel vulnerability.

Check the doors from outside the container

You do not need a shell inside a container to answer any of those questions. From the host, docker inspect prints the exact settings that decide every branch above, which makes the whole checklist scriptable. Wire it into a nightly sweep or a pipeline check so a bad flag turns up on your dashboard on a Tuesday morning instead of in somebody else's recon output.

terminal
$ docker inspect web --format \
'priv={{.HostConfig.Privileged}} caps={{.HostConfig.CapAdd}} apparmor={{.AppArmorProfile}}'
priv=true caps=[SYS_ADMIN] apparmor=unconfined
$ docker inspect web --format \
'{{range .Mounts}}{{.Source}} -> {{.Destination}} ({{.Mode}}){{"\n"}}{{end}}'
/var/run/docker.sock -> /var/run/docker.sock (rw)
/ -> /host (rw)

The fix is a stack, not a flag

No single control is trusted to hold on its own, so you stack them, and every layer charges the attacker something for the next step. Run the process as an ordinary user and 'root inside' buys nothing. Drop every capability and the capability-driven tricks have nothing to grab. Switch on a user namespace (userns = user namespace, which maps root inside the container to a powerless account outside) and even a stray write to the host lands as nobody. Keep the default seccomp profile on and a good number of the syscalls a kernel exploit reaches for get refused before the kernel ever sees them. Mount the root filesystem read-only and there is nowhere to drop a payload. A moat, a wall, and a locked door. Miss one and the others are still standing.

terminal
$ docker run -d --name web \
--user 10001:10001 \
--cap-drop ALL \
--security-opt no-new-privileges \
--read-only --tmpfs /tmp \
myapp:1.0
9f3c1d2b7a04
$ docker exec web sh -c 'id; grep CapEff /proc/self/status'
uid=10001 gid=10001 groups=10001
CapEff: 0000000000000000
Hardening cannot patch the thing that starts your container
Leaky Vessels is the uncomfortable reminder here. Take a container with a tight seccomp profile, every capability dropped, and a read-only root filesystem. The break would still have gone straight through it, because CVE-2024-21626 lived in runc's setup code, which runs before your container's restrictions are put in place. Your seccomp profile and your dropped capabilities guard the process after it starts. They have nothing to say about a flaw in the machinery that starts it. The one control that did cut the damage was running as a non-root user, because the leaked access then landed as an unprivileged account rather than as host root. So treat patching runc, containerd, and the Docker engine as a front-line container security control, not as housekeeping you get to when there is time. Track CVEs for your runtime as seriously as you track them for your base images.

Work from the assumption that the application will get hit with RCE (remote code execution, an attacker running commands of their choosing on your box), then ask what walls are left standing. Most real host takeovers come from configuration mistakes rather than fresh kernel bugs: the privileged flag, a mounted socket, a broad host path. Kernel bugs still matter, because every container on the machine shares one kernel.

Layered defense in practice is a series of reductions. Fewer privileges. Fewer tools sitting in the image for an attacker to borrow. Fewer syscalls reachable. Fewer network paths out. Then you watch what still moves, because something always does.

A tabletop exercise sells this better than a slide deck. Walk a team through an escape from a low-privilege container to the host, step by step, out loud, and stop at each step to ask which single flag would have ended it right there. Engineers who argue about --privileged in the abstract tend to stop arguing once they have watched it play out.

The same checks become your after-change ritual. When a change window closes, run them, confirm the control is still on, paste the command and its output into the ticket, and refuse to sign the change off if the reading has drifted from what you expected. Pick the tightest scope the workload can actually run under. That habit compounds across every host and every pipeline you own.

Try this

Take inventory of one running container's privilege surface: which user it runs as, which capabilities it was granted, whether it is privileged, what is mounted into it, and how it shares the process and network namespaces. Keep the output. That is your escape checklist for that workload.

terminal
$ CID=$(docker run -d alpine sleep 120)
$ docker inspect -f 'Priv={{.HostConfig.Privileged}} User={{.Config.User}} CapAdd={{.HostConfig.CapAdd}} Pid={{.HostConfig.PidMode}} Net={{.HostConfig.NetworkMode}}' $CID
Priv=false User= CapAdd=[] Pid= Net=bridge
$ docker inspect -f '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}' $CID
$ docker rm -f $CID

Takeaway

Every privilege flag and every mount is a possible ladder to the host, so count the ladders on a schedule instead of hoping nobody stacked one. Assume the app will be broken into, and make sure the walls behind it are doing real work.

Quick check
01A container runs as a non-root user with --cap-drop ALL and a read-only root filesystem. It also has /var/run/docker.sock bind-mounted into it. How much does that hardening protect the host?
Incorrect — The user and capability hardening is real, but none of it touches the socket. Talking to the Docker API (the daemon's remote-control interface) needs no capabilities and no root inside the container.
Correct — The socket is a direct line to a daemon that runs as host root. The container never fights the kernel, it asks the daemon for the host, and the other flags never enter that path.
Incorrect — It is worse than reading files. The Docker API can start a privileged container with / mounted inside it, which is full host root, not a peek at a few files.
02The lesson describes a container with a tight seccomp profile, every capability dropped, and a read-only root filesystem, then says Leaky Vessels (CVE-2024-21626) would have broken it anyway. Why did none of that hardening help?
Incorrect — No. The bug lived in runc, the low-level runtime, not in the host kernel.
Incorrect — No. Those controls apply to ordinary containers, which is the whole point of the default hardening.
Incorrect — No. Read-only root was a genuine control here, not the cause of the break.
Correct — seccomp and dropped capabilities guard the process after it starts, and a bug in the setup code runs before any of that exists.
03You land in a container and run the usual recon. You are uid=0 inside, CapEff holds the default set, there is no docker.sock, no host path is mounted, the default seccomp profile is on, and the host kernel is fully patched. Going by the prerequisite checklist, what is your route to the host?
Incorrect — No. Root inside is not host root while the namespace and capability boundary holds.
Incorrect — No. The default set leaves out CAP_SYS_ADMIN, so mounting host disks is not on the menu.
Correct — With privileged, the socket, host mounts, and the dangerous capabilities all absent, the only route left is the expensive one.
Incorrect — No. No host path is mounted, so the container's view of the filesystem holds no host files.

Related