Container Escape CVEs, by Class: What the Pattern Tells You
A container is a polite suggestion to the kernel. You ask for namespaces, a cgroup, a seccomp filter and a capability mask, the kernel agrees to pretend, and everyone gets on with their day. It is an excellent abstraction and I use it constantly. But be precise about what it is: not a boundary the way a VM is a boundary, but a configuration of one kernel that you and your neighbour are both sitting inside. "Container escape" means somebody made that shared kernel — or a host-side process acting on the container's behalf — do something the pretending was supposed to prevent.
I'm Ajay; I built PandaStack, a Firecracker microVM platform, so I have an obvious bias and I'd rather state it than smuggle it in. This is not a CVE listicle and not an argument that containers are broken. It's a tour of the escape classes, grouped by mechanism and using well-documented historical CVEs as case studies, because the classes are what generalise. Individual bugs get patched in days; the shapes that produce them have been producing them for a decade. Mechanisms and defenses only — no exploit code, and every issue below is public and long since fixed.
Class 1: handles that cross the boundary even when paths don't
The mental model most people carry is filesystem-shaped: the container has its own root, so it can't see host paths, so it can't touch host files. That model is wrong in an important way. Namespaces isolate names. They do not isolate handles. A file descriptor is a direct reference to an open kernel object, and once a process holds one the pathname that produced it is irrelevant.
The canonical case is CVE-2019-5736 in runc. The container process could reach the host's `runc` binary through `/proc/self/exe` — the magic symlink the kernel maintains to the running executable — and overwrite it. The container filesystem never contained a copy of runc; that wasn't required. The next container start on that host then ran attacker-supplied code as host root, because the thing the host executed to start containers was no longer the thing the host thought it was. The fix pattern the ecosystem adopted: run the runtime from a sealed, read-only copy of itself.
Five years later came the Leaky Vessels set, of which CVE-2024-21626 is the one everybody remembers. Different bug, same shape: a file descriptor to a host directory was left open across the boundary, and combining that leaked descriptor with control of the container's working directory let a container process resolve its way onto the host filesystem. Nobody had to defeat namespaces — the handle was already inside.
Class 2: kernel features that never expected a hostile caller
Linux is old and it is generous. Much of its functionality was designed when "local root" and "the administrator" were the same person, and the interesting question was "is this convenient?" rather than "what if the caller is trying to hurt me?" Containers changed the caller without changing the feature.
CVE-2022-0492 is the cleanest illustration. cgroups v1 has a `release_agent` mechanism: write a path into a cgroup file, and when the last task in that cgroup exits the kernel executes that program — on the host, as root. That is not a bug. That is a documented notification hook for a machine administrator. It becomes an escape primitive the moment a container can mount cgroupfs and write that file, because now a tenant chooses which program the host kernel runs. The bug was in who could reach it; the escape was what the feature had always done.
The broader form of this class is `CAP_SYS_ADMIN`, which is less a capability than a bundle of them wearing a trenchcoat. It gates mounting, and mounting is the master key: control the mount table and you control what paths resolve to, including for host tooling acting on your container. Related capabilities have their own doors. `CAP_SYS_MODULE` is load-arbitrary-kernel-code. `CAP_SYS_PTRACE` plus a shared PID namespace is attach-to-host-processes. `CAP_DAC_READ_SEARCH` historically enabled open-by-handle tricks reading files outside the container's tree.
There is no fixed list of these, and that's the uncomfortable part. Every new kernel feature reachable by an unprivileged-ish caller is a new candidate: io_uring, user namespaces themselves, eBPF, new filesystem types. The class doesn't close. It just gets audited more each year.
Class 3: path-resolution races when the host acts for the guest
Some host-side code has to reach into container-controlled territory: the runtime sets up mounts, copy tools read guest files. Every one of those is a time-of-check-to-time-of-use problem waiting for someone with a fast loop and patience, because the guest can change what a path means between the moment the host resolves it and the moment the host uses it.
CVE-2021-30465 in runc was a symlink-exchange race during mount handling: swap a path component for a symlink at the right instant, and a mount meant to land inside the container lands somewhere on the host instead. Winning a race is exactly the kind of thing attackers are structurally better at than defenders assume — they get unlimited retries, and you only have to lose once.
CVE-2019-14271 is the same class in a different tool. `docker cp` used a helper that stepped into the container's filesystem context, and loading a library from that attacker-controlled filesystem gave code execution in a process that still held host privileges. The lesson generalises well past Docker: any helper that walks into the guest's filesystem inherits the guest's hostility — debug sidecars, log collectors, backup agents, image scanners that chroot in, the little `exec` wrapper someone wrote to make on-call easier.
The mitigations are real, and all of the form "resolve more carefully": `openat2` with `RESOLVE_NO_SYMLINKS`, magic-link hardening, operating relative to already-verified descriptors instead of re-walking strings. Good engineering; it shrinks the class without deleting it, because a privileged process is still touching an unprivileged process's namespace.
Class 4: plain kernel LPE, which needs no container bug at all
This is the class people skip, and it's the most important one. Dirty COW (CVE-2016-5195) and Dirty Pipe (CVE-2022-0847) were both bugs in the kernel's memory subsystem that let an unprivileged local user write where they shouldn't and thereby become root. Neither had anything to do with containers.
That is precisely why they matter. A container shares the kernel with the host and with every other container on the box, so any local privilege escalation in that kernel is a container escape for free — the untrusted code was already local, and the bug hands it the one thing the design was withholding. Nobody needs to find a runc bug when the kernel will do it for them.
The container runtime can be flawless and it changes nothing here. If the boundary is a kernel, then "local privilege escalation" and "escape" are synonyms with different marketing.
It also has the worst operational shape. A runtime CVE is fixed by upgrading one userspace package. A kernel LPE is fixed by upgrading the kernel and rebooting every node — a fleet-wide, capacity-planned, change-managed event. The window between disclosure and actually-patched-everywhere gets measured in the units your org uses for scheduling, not the units attackers use for tooling.
Class 5: the escapes that will never get a CVE
Mount the Docker socket into a container and that container can create another container, mounting the host root, privileged, and do whatever it likes as host root. Run `--privileged` and you've handed over every capability, an unmasked `/proc`, and the device nodes. Share the host PID namespace and you can ptrace host processes. Share the host network namespace and you're on the host's loopback, where an alarming number of unauthenticated internal services live. Bind-mount a writable hostPath and you can edit whatever's under it.
None of these are vulnerabilities. Every one is documented, intentional, and working exactly as designed. They are you handing over the keys and then filing a bug report about the lock. In my experience they cause more real incidents than the CVE classes above, because a CVE gets a patch and a Slack thread, while a bad `securityContext` gets copy-pasted into forty more manifests.
So audit for them, mechanically, on a schedule. Here's the checklist I actually run — nothing clever, just the questions in order.
#!/usr/bin/env bash
# audit-container.sh <name|id> -- look for the "working as designed" escapes.
# These are configuration findings, not vulnerabilities. That is the problem.
set -uo pipefail
C="${1:?usage: audit-container.sh <container-name-or-id>}"
PID=$(docker inspect -f '{{.State.Pid}}' "$C") || exit 1
echo "== $C (host pid $PID) =="
# 1. Container-runtime socket mounted in = host root with extra steps.
docker inspect -f '{{range .Mounts}}{{.Source}}:{{.Destination}}{{println}}{{end}}' "$C" \
| grep -E 'docker\.sock|containerd\.sock|crio\.sock' \
&& echo "FAIL: container runtime socket is mounted"
# 2. --privileged: all caps, unmasked /proc, host devices, writable /sys.
[ "$(docker inspect -f '{{.HostConfig.Privileged}}' "$C")" = "true" ] \
&& echo "FAIL: --privileged"
# 3. Capabilities PID 1 actually holds (the manifest can lie; /proc cannot).
CAPEFF=$(awk '/^CapEff/{print $2}' "/proc/$PID/status")
echo "CapEff: $CAPEFF"
capsh --decode="$CAPEFF" | tr ',' '\n' \
| grep -E 'sys_admin|sys_module|sys_ptrace|dac_read_search|bpf|sys_rawio' \
&& echo "WARN: capability with a documented escape path"
# 4. Shared namespaces erase the boundary before anyone needs a bug.
docker inspect -f 'pid={{.HostConfig.PidMode}} net={{.HostConfig.NetworkMode}} ipc={{.HostConfig.IpcMode}}' "$C"
# 5. Writable host bind mounts.
docker inspect -f '{{range .Mounts}}{{.Type}} {{.Source}} rw={{.RW}}{{println}}{{end}}' "$C" \
| awk '$1=="bind" && $3=="rw=true" {print "WARN: writable host bind: " $2}'
# 6. Is anything actually confining syscalls? Seccomp 0 = no filter at all.
docker inspect -f 'apparmor={{.AppArmorProfile}} secopt={{.HostConfig.SecurityOpt}}' "$C"
awk '/^Seccomp:/{print "Seccomp mode (0=disabled, 2=filtered): " $2}' "/proc/$PID/status"
# 7. User namespace? If uid_map shows "0 0 4294967295", uid 0 in the
# container is uid 0 on the host and you are one kernel bug from done.
echo "uid_map: $(cat /proc/$PID/uid_map)"
docker inspect -f 'user={{.Config.User}}' "$C"What the pattern actually says (and what genuinely helps)
Here is where I decline to do the cheap thing. The obvious read of a list like this is "container runtimes are insecure," and that read is wrong. runc, containerd and CRI-O are well-engineered, maintained by people who ship coordinated fixes fast and have hardened entire classes out of existence. The bugs keep arriving for a structural rather than moral reason: the attack surface of a container is the entire Linux syscall interface, plus every kernel subsystem reachable through it, plus every host-side process that must touch guest-controlled state. Volume of surface, not quality of code.
Which means the useful defensive move isn't "pick a better runtime." It's shrinking the surface and shortening the exposure window. If you're running containers — and most of the time you should be — this list is worth more than any single CVE mitigation:
- Rootless, if you can. Daemon and container both run as an unprivileged uid, so "root in the container" maps to a normal user on the host. Highest-leverage single change available.
- User namespaces even when not fully rootless, so container uid 0 is not host uid 0.
- Drop ALL capabilities, add back only what the workload provably needs — usually none. `CAP_SYS_ADMIN` in a manifest should require a written justification.
- A seccomp profile — the runtime default beats nothing, a workload-specific allowlist beats that. Every syscall you block is surface you no longer have to trust.
- no-new-privileges, so setuid binaries in the image can't re-escalate; plus a read-only root filesystem and a small noexec tmpfs, which breaks the drop-a-payload-and-run-it step.
- No container-runtime socket, ever, in a workload container. If CI needs to build images, use a rootless builder, not the daemon socket.
- Patch the kernel aggressively: Class 4 cannot be mitigated at the container layer, so your patch cadence is literally the defense.
- gVisor or Kata for a stronger boundary short of your own VM fleet — gVisor interposes a userspace kernel on the syscall path, Kata puts a real VM under the pod interface.
# Rootless daemon + a hardened run. This shrinks the surface a lot.
# It does not change the category: every syscall below still lands in the
# same kernel your neighbours are sitting on.
dockerd-rootless-setuptool.sh install
export DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock"
docker run --rm \
--user 10001:10001 \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--security-opt=seccomp=./seccomp-strict.json \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--pids-limit=256 --memory=512m --cpus=1 \
--network=none \
ghcr.io/example/worker@sha256:0000000000000000000000000000000000000000000000000000000000000000 \
/usr/local/bin/worker
# Deliberately absent: -v /var/run/docker.sock, --privileged,
# --pid=host, --net=host, and any writable bind mount of a host path.# The Kubernetes equivalent. Same caveat: fewer doors, same building.
apiVersion: v1
kind: Pod
metadata:
name: hardened-worker
spec:
hostPID: false
hostIPC: false
hostNetwork: false
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault # or Localhost + your own allowlist
containers:
- name: worker
image: ghcr.io/example/worker@sha256:0000000000000000000000000000000000000000000000000000000000000000
securityContext:
privileged: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
limits: { cpu: "1", memory: 512Mi }
volumeMounts:
- name: scratch
mountPath: /tmp
volumes:
- name: scratch
emptyDir: {}
# Enforce it with Pod Security Admission (restricted) or a policy engine,
# because the manifest you review is not the manifest someone ships at 2am.The microVM argument, stated fairly
Now the part where I'm selling something, flagged as such. A Firecracker microVM guest has its own kernel, running behind hardware virtualization via KVM. That changes what the four CVE classes above mean, and it's worth being precise about how.
A runc descriptor leak inside the guest gets you guest root. A `release_agent` trick inside the guest gets you guest root. Dirty Pipe inside the guest gets you guest root. In every case guest root is where the untrusted code already was. The classes don't stop existing — they stop being escapes, because what they escape into is a disposable VM whose whole purpose was to hold that code. Reaching the host now needs a bug in the virtual machine monitor or the hardware boundary itself: a handful of paravirtual devices and a narrow ioctl interface, rather than the whole syscall table.
The honest caveats, because a boundary sold as unbreakable is a boundary nobody audits. VMM vulnerabilities are real and have been found in every hypervisor looked at hard, and microarchitectural side channels cut across all of this and are fixed by no VMM. Firecracker's own answer is layering: a minimal device model, a seccomp filter on the VMM process itself, and a jailer that drops it into its own namespaces and cgroup — on the assumption that the VMM might one day be the compromised thing. That posture is an admission that "has its own kernel" is a much better bet, not a proof. I've written separately on VM escape attacks and on side channels in multi-tenant compute; both are linked below and both read more pessimistically than this section.
What that looks like in practice is boring, which is the highest compliment available in security. The same hostile command runs, and its success is uninteresting:
from pandastack import Sandbox
# A command written by something you do not trust -- a model, a customer,
# a package's postinstall script. In a container, several of these probes
# are reconnaissance. In a microVM they are just facts about a guest.
probe = r"""
id
ls -l /proc/self/exe # the CVE-2019-5736 handle: guest's runc, if any
cat /proc/self/uid_map # who am I, really?
ls /var/run/docker.sock 2>&1 # no host daemon socket exists to find
uname -r # this kernel is MINE, not the host's
cat /proc/self/status | grep -E 'CapEff|Seccomp'
"""
with Sandbox.create(template="base", ttl_seconds=300) as sbx:
sbx.filesystem.write("/workspace/probe.sh", probe)
r = sbx.exec("sh /workspace/probe.sh", timeout_seconds=30)
print("exit:", r.exit_code)
print(r.stdout)
if r.stderr:
print("stderr:", r.stderr)
# VM is destroyed here. Root in it bought the caller a VM that no
# longer exists, on a kernel nobody else was using.The design point isn't that root is impossible in there. It's that root in there was never the thing being protected. Every sandbox gets its own guest kernel, its own network namespace and tap device, and a lifetime the length of one task.
Comparing the boundaries honestly
Six options, roughly ordered by strength. Performance and operational cost move with your workload and your team, so treat the direction as reliable and the magnitude as something to measure yourself.
- Hardened container (caps dropped, seccomp, read-only, no socket) — Surface: the full syscall interface minus what seccomp blocks. Classes: all four CVE classes; config escapes eliminated by construction. Cost: essentially native. Familiarity: maximal — every tool and engineer already speaks it.
- Rootless container — Surface: same syscall interface, but the attacker lands as an unprivileged host uid. Classes: descriptor leaks and path races substantially defanged; kernel LPE applies in full, since the bug is what grants privilege. Cost: near-native. Familiarity: high, with real edge cases around ports, cgroups delegation and storage drivers.
- gVisor — Surface: a userspace kernel intercepts syscalls, so most never reach the host kernel directly; the surface becomes gVisor's own implementation plus the narrow set it forwards. Classes: sharply reduces 1-4, and adds its own bug surface instead. Cost: syscall- and I/O-heavy workloads pay real overhead; compute-bound ones barely notice. Familiarity: drop-in-ish as an OCI runtime, with genuine compatibility gaps.
- Kata Containers — Surface: a real VM per pod behind the container interface, so the host kernel is reached only through the VMM. Classes: the four above become guest-local; VMM and hardware classes now apply. Cost: VM-shaped startup and memory overhead. Familiarity: high at the Kubernetes layer, lower underneath — you now operate a hypervisor whether you wanted to or not.
- Firecracker microVM — Surface: own guest kernel behind KVM; host exposure is a minimal device model plus a seccomp-filtered, jailed VMM. Classes: 1-4 become guest-local and uninteresting; VMM bugs and side channels remain. Cost: virtualization is cheap at steady state, so the real cost is boot and memory — on PandaStack, snapshot-restore creates run about 179ms p50 / 203ms p99, with roughly 3s only for a template's first-ever cold boot. Familiarity: lowest here; the API is not the Docker API.
- Separate physical host — Surface: no shared kernel, no shared VMM, no shared cores. Classes: none of the above; you're down to the network and the supply chain. Cost: perfect isolation, catastrophic utilisation. Familiarity: total, and totally impractical below "one tenant, one machine." The honest ceiling every other row compromises against.
When this is overkill, and how to think about it
Most containers in the world run code the team that deployed them wrote. For that — your own microservices, batch jobs, build steps — a container is completely appropriate, and a VM per process is an expensive way to feel safe about a threat you don't have. The relevant risk there is supply-chain: a compromised dependency, a typosquatted package. The answer is provenance, pinning and lockfile discipline, not a hypervisor.
The rule I use: match the boundary to the trust level, and be honest about which one you're in. If you'd be comfortable with the code running as an unprivileged user directly on the host, a container is fine — you've just made it tidier. If your answer to "what if this code is actively trying to break out?" is anything other than "then it gets a machine that doesn't matter," you are betting on a shared kernel. That bet isn't crazy — it's the one most of the industry makes daily and mostly wins. But you re-take it every CVE cycle, and the payout schedule is set by your kernel patch cadence, not your architecture.
Whichever boundary you pick, keep doing the boring things. Patch the kernel. Run the audit script. Refuse the Docker socket. Read a `securityContext` in review the way you'd read an IAM policy. These classes will keep producing bugs whatever you deploy on; the only variable you control is what one is worth when it lands.
Frequently asked questions
What is a container escape, exactly?
It's when code inside a container obtains privileges or access on the host that its namespaces, cgroups, capability mask and seccomp filter were meant to prevent. Because a container is a configuration of one shared kernel rather than a separate machine, an escape can come from a runtime bug (a leaked file descriptor, a mount race), from a kernel feature reachable by the container (cgroups release_agent), from a plain kernel privilege-escalation bug that has nothing to do with containers at all, or simply from a permissive configuration such as a mounted Docker socket. Only the first three ever get CVEs.
Does a container escape CVE mean container runtimes are insecure?
No, and that reading misses the point. runc, containerd and CRI-O are carefully engineered and their maintainers ship coordinated fixes quickly. The bugs keep appearing because the surface being defended is the entire Linux syscall interface plus every kernel subsystem reachable through it plus every host-side process that must touch guest-controlled state. That is a structural property of sharing a kernel, not a code-quality problem. Better runtime code raises the difficulty of each individual bug; it does not shrink the surface that keeps producing them.
Do Dirty COW and Dirty Pipe count as container escapes?
They weren't container bugs — both were flaws in the Linux kernel's memory subsystem that let an unprivileged local user escalate to root, with no container runtime involved. But that's exactly why they matter here. A container shares the kernel with its host and its neighbours, so any local privilege escalation in that kernel is a container escape for free: the untrusted code is already local, and the bug hands it the privilege the isolation was withholding. This is also the class you cannot mitigate at the container layer — your defense is kernel patch cadence.
What actually reduces container escape risk the most?
In rough order of leverage: go rootless (or at minimum use user namespaces) so container root isn't host root; drop all capabilities and add back only what's provably needed; apply a seccomp profile; set no-new-privileges and a read-only root filesystem; never mount the container runtime socket into a workload; avoid privileged, hostPID, hostNetwork and writable hostPath mounts; and patch the kernel aggressively. If you need a stronger boundary without operating a VM fleet, gVisor and Kata Containers both sit between hardened containers and full virtualization.
Is a Firecracker microVM immune to these escapes?
No — but it changes what they buy an attacker. Each microVM has its own guest kernel behind hardware virtualization, so a runc descriptor leak, a cgroups release_agent trick or a Dirty Pipe-style kernel bug inside the guest yields guest root, which is where untrusted code already was. Reaching the host requires a bug in the virtual machine monitor or the hardware boundary — a much smaller surface than the full syscall interface, but not an empty one. VMM vulnerabilities and microarchitectural side channels are real, which is why Firecracker layers seccomp and a jailer on top of the VM boundary.
Keep reading
- VM escape attacks explained — The pessimistic companion: what breaks the hypervisor boundary when the container boundary is no longer the weak link.
- Side-channel attacks in multi-tenant compute — The class that cuts across containers, VMs and hardware alike, and that no isolation layer fully removes.
- Linux capabilities, explained for sandboxing — A closer look at CAP_SYS_ADMIN and friends — the capability bits behind Class 2.
- Firecracker vs Kata vs gVisor — The three stronger-boundary options from the comparison, weighed against each other in detail.
49ms p50 cold start. Fork, snapshot, and scale to zero.