Container escape: how attackers reach the host, and how to close it
A container is a process in its own namespaces on a kernel shared with the host. Most "escapes" are not a broken boundary at all — they are handed keys: access to the daemon, a mounted socket, the --privileged flag. Real kernel escapes are rarer but worse. Here are both classes, and what closes each.
Full technical treatment
TL;DR
- A container is an ordinary program on the same kernel as the host; the kernel just shows it a trimmed view of the world. There is no virtual machine with a separate kernel here.
- So most "escapes" are not a break-in at all. The keys were handed over: a user in the
dockergroup, a mounted daemon socket, the--privilegedflag. Each grants host access by documentation. - A real escape is rarer: a bug in the shared runtime (
runc) yields root on the host, because the kernel is shared by everyone. - Defense: don't hand out the
dockergroup or the socket, drop--privileged, run without root, patchrunc. For untrusted code, use a microVM instead of a container.
A container is not a separate machine
It is easy to think of a container as a small virtual machine. It is not. A container is an ordinary process on a kernel shared with the host. The kernel shows it its own slice: its own processes, its own network, its own filesystem. But the kernel is the same one the host uses. A container's isolation is a floor plan inside one apartment, not a separate house.
From that follow two very different ways to "escape" a container onto the host.
Way one: the keys were handed over
Most often nobody breaks anything — host access was granted by configuration.
The docker group is root. The Docker daemon runs as root, and whoever can
command it controls the host. Docker's docs ask outright that only trusted people
reach the daemon. And starting a container with the host's root mounted is a
built-in feature:
docker run -v /:/host --rm -it alpine chroot /host sh
# this is a root shell in the host's files
Nothing was broken: the command did what it promises. So adding someone to the
docker group is the same as handing them passwordless sudo.
A mounted socket and --privileged are the same thing in other words. If
/var/run/docker.sock is mounted into a container, from inside you can command
the host's daemon. The --privileged flag lifts almost all of a container's
restrictions. Both are an open door, not a break-in.
Way two: a bug in the shared runtime
This is a real escape: not an open door but a punched wall. Since the kernel and
runtime are shared with the host, a bug in them is a way out. That is how two
well-known runc bugs worked (CVE-2019-5736 and CVE-2024-21626): both yielded
root on the host — because the kernel is shared by everyone. Both are long since
fixed; the point is to patch in time.
The figure below shows the two models side by side — a container with a shared kernel and a microVM with a hypervisor — and which boundary each class crosses.
How to close it
- Don't hand out keys. The
dockergroup — trusted people only; don't mountdocker.sock; drop--privileged. - Narrow privileges. Run as a non-root user, drop unneeded privileges, enable rootless mode — then even an escape gives no root on the host.
- Patch
runcand don't disable the kernel's defenses (seccomp, AppArmor/SELinux). - For untrusted code, remove the shared kernel. gVisor, Kata, or a microVM (Firecracker): they have their own kernel and a hypervisor boundary, and the runc class of escapes no longer reaches the host.
TL;DR
A container is an ordinary process to which the kernel has handed its own
namespaces, cgroups, and a trimmed set of capabilities. The kernel itself is
shared with the host. From that come two different classes of "escape," and
they must not be confused.
Class 1 — handed keys. Most often there is no break-in: control of the host
was given away. A user in the docker group, a /var/run/docker.sock mounted
into a container, the --privileged flag, a bind of the host's / — each of
these grants full host access by documentation. There is nothing to break:
the door is open.
Class 2 — a bug in the shared runtime or kernel. Rarer and harder, but this
is the "real escape": runc (CVE-2019-5736, CVE-2024-21626) yields host root not
because isolation is weak, but because the runtime and the kernel are shared.
What closes each. Class 1: privilege hygiene — don't hand out the docker
group, don't mount the socket, drop --privileged, drop unneeded
capabilities, run rootless. Class 2: patch runc and, for untrusted code,
remove the shared kernel entirely — gVisor, Kata, a microVM (Firecracker). No
shared kernel, no class 2.
What a container is, and where its boundary runs
A container is not a virtual machine. It is a process on the same kernel as
the host, to which the kernel shows a trimmed view of the world: its own process
IDs, its own network, its own filesystem — those are namespaces. How much
resource it may use is decided by control groups (cgroups). What it is
allowed to do in the kernel is limited by a set of capabilities.
The key word is "shared." All three mechanisms live in one kernel with the host. So a container's isolation is not the wall of a virtual machine but a floor plan inside one apartment. NIST's container security guide puts containers on a boundary weaker than a hypervisor's outright. Both escape classes grow from that: somewhere the floor plan was given away, somewhere it was punched through by a bug.
What counts as trusted here
Before the list of defences it is worth settling the frame, or the argument "is a container secure" has no answer. NIST's container security guide sets that frame twice.
The first is about the class of boundary, and it is stated outright:
While containers provide a strong degree of isolation, they do not offer as clear
and concrete of a security boundary as a VM. Because containers share the same
kernel and can be run with varying capabilities and privileges on a host, the
degree of segmentation between them is far less than that provided to VMs by a
hypervisor.
The reason is named in the same document, in the section on the shared kernel:
Although containers provide strong software-level isolation of resources, the use
of a shared kernel invariably results in a larger inter-object attack surface
than seen with hypervisors, even for container-specific OSs. In other words, the
level of isolation provided by container runtimes is not as high as that provided
by hypervisors.
The second is where the document draws the boundary of its own scope, and that list is exactly what to keep in mind while reading any promise of isolation:
All other risks involving the core components, as well as risks involving
non-core container technology components, including developer systems, testing
and accreditation systems, administrator systems, and host hardware and virtual
machine managers, are outside the scope of this document.
Hence the practical conclusion, which is the threat model in one line: everything that shares your kernel, you treat as equally trusted. Not "isolated" but trusted — because the boundary between them is weaker than a hypervisor's, and it is configured by you rather than by a vendor.
It follows too that the runtime is part of the boundary. There is no direct "keep
your runtime current" requirement in the document; there are three neighbouring
statements from which it follows. A runtime vulnerability leads to escape:
While relatively uncommon, vulnerabilities within the runtime software are particularly dangerous if they allow "container escape" scenarios
.
And hence the obligation: The container runtime must be carefully monitored for vulnerabilities, and when problems are detected, they must be remediated quickly
.
And from "equally trusted" follows a placement rule, named in the document explicitly:
Only group containers with the same purpose, sensitivity, and threat posture on a
single host OS kernel to allow for additional defense in depth. While most
container platforms do an effective job of isolating containers from each other
and from the host OS, it may be an unnecessary risk to run apps of different
sensitivity levels together on the same host OS.
So the decision "where do we run this" is taken before the configuration, not after it: by whether purpose, sensitivity and threat posture match the neighbours'.
Class 1: the escape that never was — handed keys
The most common "hole" is not a hole. Control of the host was given away by configuration, and the attacker only has to use it. Three typical cases; in all three kernel isolation is irrelevant.
The docker group is root on the host
The Docker daemon runs as root. Whoever can give it commands effectively controls
the host. Docker's documentation says so outright: "Only trusted users should
be allowed to control your Docker daemon." Membership in the docker group
grants exactly that control — no sudo, no password.
Why that equals root is clear from another passage in the same docs. Docker lets
you mount any host directory into a container without restricting access:
"you can start a container where the /host directory is the / directory on
your host; and the container can alter your host filesystem without any
restriction." So anyone who can start a container can mount the host's root and
write to it:
docker run -v /:/host --rm -it alpine chroot /host sh
# this is now a root shell in the host's filesystem
This is not an exploit or a vulnerability — it is documented behaviour. No
isolation mechanism failed here: the command did exactly what it promises. So the
docker group is not "a convenience for developers" but a grant of root, and it
should be treated like handing out passwordless sudo.
A mounted docker.sock
The same thing, from inside a container. If /var/run/docker.sock is mounted
into a container (a common trick for CI and "Docker-in-Docker"), the process
inside gets a channel to the host's daemon — and therefore everything the
docker group gives: ask for a new container with / mounted. OWASP makes this
rule number one: "Do not expose the Docker daemon socket (even to the
containers)" — "Giving someone access to it is equivalent to giving
unrestricted root access to your host."
--privileged and extra capabilities
The --privileged flag lifts almost every restriction: it hands the container
the full set of capabilities, access to host devices, and drops the seccomp and
AppArmor profiles. From such a container the host is one step away. The same goes
for individual dangerous privileges like CAP_SYS_ADMIN, or bind-mounting host
paths. OWASP: "Do not run containers with the --privileged flag."
Takeaway for class 1: most "container escapes" in real incidents are this one. Breaking the kernel is not required, because host access was handed over by configuration. The good news: it is closed by configuration too — see below.
Class 2: the real escape — a bug in the shared runtime or kernel
Now the "escape" in the strict sense: the attacker crosses a boundary nobody opened. Since the kernel and runtime are shared with the host, a bug in them is a bridge outward. Two known examples — by what failed, with no reproduction steps.
CVE-2019-5736. Verbatim: "runc through 1.0-rc6 … allows attackers to
overwrite the host runc binary (and consequently obtain host root access)." What
failed was not a kernel isolation mechanism but runc — the runtime shared by
the container and the host. Overwriting it could yield root on the host.
CVE-2024-21626. Verbatim: "due to an internal file descriptor leak, an
attacker could cause a newly-spawned container process … to have a working
directory in the host filesystem namespace." The runtime again; fixed in
runc 1.1.12. A descriptor pointing into the host's filesystem was reachable
from inside — because there is only one, shared kernel.
What both share: the container and the host have one kernel, and a runtime failure bridges to it. That is what makes class 2 possible at all — and it points to the one defense that removes it entirely (the last section).
How to close it
Defense is layered: first don't hand out access (class 1), then narrow what remains, and finally — for untrusted code — remove the shared kernel (class 2). The rules below come from the OWASP Docker Security Cheat Sheet and Docker's docs.
Don't hand out control of the daemon.
- The
dockergroup — trusted people only; treat membership assudoand audit it regularly. - Don't mount
/var/run/docker.sockinto containers; don't expose the daemon over TCP. - Rootless mode: the daemon and containers run as an unprivileged user, so even a container escape does not give root on the host.
Narrow the container's privileges.
- No
--privileged. Drop allcapabilitiesand add back only what is needed. - Run as a non-root user inside the container — OWASP calls this the best way to prevent privilege escalation.
--security-opt=no-new-privilegessosetuidbinaries cannot raise privileges.--read-onlyfor the root filesystem wherever possible.
Keep the kernel's own defenses on.
- Don't disable the default seccomp profile; keep AppArmor or SELinux.
- Enable user namespaces: root inside the container stops being root on the host.
Against class 2 — patches and a different boundary.
- Patch the runtime: keep
runcon the current patch release of its branch and read the project's security advisories. Naming a specific version here would be pointless: it moves with every advisory, and yesterday's “new enough” is today's vulnerable. - For untrusted code, remove the shared kernel entirely: gVisor (system-call interception), Kata Containers, or a microVM (Firecracker). They have their own guest kernel, their boundary with the host is a hypervisor with a narrow interface, and the runc class of escapes no longer reaches the host: there is nothing to break through, no shared kernel.
The same thing in Kubernetes: three levels, and what each forbids
Everything said above about docker run flags is expressed declaratively in
Kubernetes, and the list need not be assembled by hand: it is standardised as
three policies.
The Pod Security Standards define three different policies to broadly cover the
security spectrum. These policies are cumulative and range from
highly-permissive to highly-restrictive.
"Cumulative" is doing work here: Restricted includes everything in Baseline.
Privileged is the absence of restrictions, and the documentation does not
soften it: Unrestricted policy, providing the widest possible level of permissions. This policy allows for known privilege escalations
.
It is meant for system workloads run by trusted people — that is, for the very
"the keys were handed over deliberately" case of class 1 above.
Baseline is Minimally restrictive policy which prevents known privilege escalations
.
Five of its rows map onto "How to close it" one for one — the same prohibitions,
written as manifest fields (the policy itself has more controls; only the
overlapping ones are here):
| what is forbidden | the policy's wording | where in the manifest |
|---|---|---|
| privileged Pods | Privileged Pods disable most security mechanisms and must be disallowed. | securityContext.privileged |
| host namespaces | Sharing the host namespaces must be disallowed. | spec.hostNetwork, spec.hostPID, spec.hostIPC |
| extra capabilities | Adding additional capabilities beyond those listed below must be disallowed. | securityContext.capabilities.add |
| turning seccomp off | Seccomp profile must not be explicitly set to Unconfined. | securityContext.seccompProfile.type |
hostPath volumes | HostPath volumes must be forbidden. | spec.volumes[*].hostPath |
Baseline's allowed capability list is thirteen names: AUDIT_WRITE, CHOWN,
DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE,
SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT.
Restricted is Heavily restricted policy, following current Pod hardening best practices
,
and what it adds is what was called "narrow the privileges" above:
| what is added | the policy's wording |
|---|---|
| no privilege escalation | Privilege escalation (such as via set-user-ID or set-group-ID file mode) should not be allowed. |
| run as non-root | Containers must be required to run as non-root users. |
| and not as UID 0 explicitly | Containers must not set runAsUser to 0 |
| seccomp mandatory | Seccomp profile must be explicitly set to one of the allowed values. Both the Unconfined profile and the absence of a profile are prohibited. |
| all capabilities dropped | Containers must drop ALL capabilities, and are only permitted to add back the NET_BIND_SERVICE capability. |
Note the difference in the seccomp row: Baseline forbids turning the profile off, Restricted requires setting it explicitly — the absence of a profile is forbidden too. Those are different requirements, and the second is stronger.
Take the fields' semantics from the API types rather than from retellings. The two places people get wrong most often:
AllowPrivilegeEscalation controls whether a process can gain more privileges than
its parent process. This bool directly controls if the no_new_privs flag will be
set on the container process. AllowPrivilegeEscalation is true always when the
container is: 1) run as Privileged 2) has CAP_SYS_ADMIN
So you cannot set allowPrivilegeEscalation: false and leave privileged mode on
at the same time: the second wins, silently.
Indicates that the container must run as a non-root user. If true, the Kubelet
will validate the image at runtime to ensure that it does not run as UID 0 (root)
and fail to start the container if it does. If unset or false, no such validation
will be performed.
runAsNonRoot: true is a check, not an assignment of a user. An image built
to run as root will not start under it; the user has to be set in the image or
through runAsUser.
How this is switched on. The policies are applied by namespace labels —
pod-security.kubernetes.io/<mode>: <level> — and there are three modes:
enforce (Policy violations will cause the pod to be rejected
), audit
and warn. The rollout order follows by itself: warn and audit first, then
enforce once the list of violations is empty.
And what closes class 2. Removing the shared kernel in Kubernetes is not a
separate infrastructure but a field in the manifest: RuntimeClass has been
stable since 1.20.
You can set a different RuntimeClass between different Pods to provide a balance
of performance versus security. For example, if part of your workload deserves a
high level of information security assurance, you might choose to schedule those
Pods so that they run in a container runtime that uses hardware virtualization.
You'd then benefit from the extra isolation of the alternative runtime, at the
expense of some additional overhead.
That is the same decision as "gVisor or Kata for untrusted code" in the previous section, only taken per Pod rather than per node. And it is taken by the same rule as in the threat model above: by whether purpose, sensitivity and threat posture match.
What to take away
"Container escape" is most often not a break-in but a grant of privilege.
Before reaching for a CVE, check the configuration: who is in the docker group,
where the socket is mounted, where --privileged is set. Class 1 is closed by
hygiene, not patches.
The docker group = root on the host. This is documented behaviour, not a
vulnerability. Granting it is the same as granting passwordless sudo.
A real escape relies on a shared kernel. Patches (runc) close known holes;
but while the kernel is shared, the class remains. Only a different boundary
removes it — gVisor, Kata, a microVM.
Ask not "can the container be broken," but "what did we hand over, and what boundary protects the rest." For an ordinary container that is a floor plan on a shared kernel; for a microVM it is a hypervisor, whose surface is an order of magnitude smaller.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A container is an ordinary process to which the kernel has handed its own namespaces,
cgroups, and a trimmed set ofcapabilities. The kernel itself is shared with the host. From that come two different classes of "escape," and they must not be confused. - Class 1 — handed keys. Most often there is no break-in: control of the host was given away. A user in the
dockergroup, a/var/run/docker.sockmounted into a container, the--privilegedflag, a bind of the host's/— each of these grants full host access by documentation. There is nothing to break: the door is open. - Class 2 — a bug in the shared runtime or kernel. Rarer and harder, but this is the "real escape":
runc(CVE-2019-5736, CVE-2024-21626) yields host root not because isolation is weak, but because the runtime and the kernel are shared. - What closes each. Class 1: privilege hygiene — don't hand out the
dockergroup, don't mount the socket, drop--privileged, drop unneededcapabilities, run rootless. Class 2: patchruncand, for untrusted code, remove the shared kernel entirely — gVisor, Kata, a microVM (Firecracker). No shared kernel, no class 2.
In fact
- A container's kernel is shared with the host; namespaces, cgroups and capabilities are a floor plan inside one kernel, not a separate machine. NIST SP 800-190 puts containers on a boundary weaker than a hypervisor's. A virtual machine has its own kernel, and its boundary is the hypervisor.
- Most often it is not a break-in at all. Host access is handed out by configuration: membership in the
dockergroup, a mounted/var/run/docker.sock, the--privilegedflag. Each grants full host access by documentation — there is nothing to break. - It is a grant of root on the host. The daemon runs as root, and Docker's docs ask: “Only trusted users should be allowed to control your Docker daemon.” A group member can start a container with
/mounted and write to the host filesystem — a built-in feature, not a bug. - Patches close known class-2 holes (CVE-2019-5736, CVE-2024-21626), but while the kernel is shared with the host the class remains and the next one will appear. And class 1 (handed keys) is not fixed by patches at all — only by configuration.
- By itself, no: root inside a container is bounded by namespaces, cgroups and trimmed capabilities on a shared kernel. It becomes dangerous when privileges were handed out (
--privileged, a mounted socket) or when there is a bug in the shared runtime. User namespaces even make root inside a non-root on the host.
What is covered
- What a container is, and where its boundary runs
- What counts as trusted here
- Class 1: the escape that never was — handed keys
- Class 2: the real escape — a bug in the shared runtime or kernel
- How to close it
- The same thing in Kubernetes: three levels, and what each forbids
- What to take away
Common misconceptions
“A container isolates just like a virtual machine.”
A container's kernel is shared with the host; namespaces, cgroups and capabilities are a floor plan inside one kernel, not a separate machine. NIST SP 800-190 puts containers on a boundary weaker than a hypervisor's. A virtual machine has its own kernel, and its boundary is the hypervisor.
“A container escape is always a hard kernel exploit.”
Most often it is not a break-in at all. Host access is handed out by configuration: membership in the docker group, a mounted /var/run/docker.sock, the --privileged flag. Each grants full host access by documentation — there is nothing to break.
“Adding a user to the docker group is just convenient.”
It is a grant of root on the host. The daemon runs as root, and Docker's docs ask: “Only trusted users should be allowed to control your Docker daemon.” A group member can start a container with / mounted and write to the host filesystem — a built-in feature, not a bug.
“Patch runc and container escapes are closed.”
Patches close known class-2 holes (CVE-2019-5736, CVE-2024-21626), but while the kernel is shared with the host the class remains and the next one will appear. And class 1 (handed keys) is not fixed by patches at all — only by configuration.
“Since I'm root inside the container, I'm almost on the host.”
By itself, no: root inside a container is bounded by namespaces, cgroups and trimmed capabilities on a shared kernel. It becomes dangerous when privileges were handed out (--privileged, a mounted socket) or when there is a bug in the shared runtime. User namespaces even make root inside a non-root on the host.
Knowledge check
A developer asks to be added to the docker group “so I don't have to type sudo.” What does that mean for host security?
Sources & further reading
7 SOURCES
- Docker Engine security — Docker daemon attack surfaceOfficial documentation. The primary source for two load-bearing facts here. Verbatim: “Only trusted users should be allowed to control your Docker daemon” — i.e. daemon access equals root on the host. And the mount mechanism: “you can start a container where the /host directory is the / directory on your host; and the container can alter your host filesystem without any restriction”. This is documented behaviour, not a vulnerability — which is exactly why it is dangerous.https://docs.docker.com/engine/security/
- OWASP Docker Security Cheat SheetOfficial documentation. The set of defensive rules the “How to close it” section leans on: do not expose /var/run/docker.sock, do not run with --privileged, drop capabilities and add back only what is needed, run as a non-root user, --security-opt=no-new-privileges, do not disable the default seccomp/AppArmor profile, read-only filesystem, rootless mode.https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html
- CVE-2019-5736 — overwriting the runtime binarySource. An example of a real escape through the shared runtime. Verbatim: “runc through 1.0-rc6 … allows attackers to overwrite the host runc binary (and consequently obtain host root access)”. Cited as an analysis of the failure class, with no reproduction step.https://www.cvedetails.com/cve/CVE-2019-5736/
- CVE-2024-21626 — a leaked descriptor to a host directorySource. The second example. Verbatim: “due to an internal file descriptor leak, an attacker could cause a newly-spawned container process … to have a working directory in the host filesystem namespace”. Fixed in runc 1.1.12. Again a runtime failure around a shared kernel; cited as analysis, not instruction.https://www.tenable.com/cve/CVE-2024-21626
- NIST SP 800-190 — Application Container Security GuideOfficial documentation. The industry guide to container security: threat model, image/runtime/orchestrator risks, least privilege. The basis for the claim that a container's isolation is not a security boundary of the same class as a virtual machine's.https://csrc.nist.gov/pubs/sp/800/190/final
- Firecracker — DesignOfficial documentation. Why untrusted code is run in a microVM: it has no kernel shared with the host, and its boundary is the KVM hypervisor with a narrow interface. Hence the article's conclusion: the runc class of escapes does not reach the host on a microVM (or gVisor/Kata) — there is no shared kernel to break through.https://github.com/firecracker-microvm/firecracker/blob/main/docs/design.md
- Kubernetes — Pod Security Standards, RuntimeClass, core/v1 SecurityContextOfficial documentation. The three policies and what each forbids: "These policies are cumulative and range from highly-permissive to highly-restrictive." The Baseline/Restricted difference on seccomp is taken verbatim: the first forbids "Seccomp profile must not be explicitly set to Unconfined", the second requires "Seccomp profile must be explicitly set to one of the allowed values. Both the Unconfined profile and the absence of a profile are prohibited." The field semantics come from the API types: "AllowPrivilegeEscalation is true always when the container is: 1) run as Privileged 2) has CAP_SYS_ADMIN" and "runAsNonRoot… the Kubelet will validate the image at runtime."https://kubernetes.io/docs/concepts/security/pod-security-standards/