The escape mindset
From one popped container to the whole host.
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.
# 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.
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.
$ 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.
$ docker run -d --name web \--user 10001:10001 \--cap-drop ALL \--security-opt no-new-privileges \--read-only --tmpfs /tmp \myapp:1.09f3c1d2b7a04$ docker exec web sh -c 'id; grep CapEff /proc/self/status'uid=10001 gid=10001 groups=10001CapEff: 0000000000000000
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.
$ 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}}' $CIDPriv=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.