Deep Engineering
Intermediate·Published·20 MIN

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

NIST SP 800-190, section 2.2

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.

NIST SP 800-190, section 3.5.2

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.

NIST SP 800-190, section 3

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.

NIST SP 800-190, Executive Summary

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 docker group — trusted people only; treat membership as sudo and audit it regularly.
  • Don't mount /var/run/docker.sock into 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 all capabilities and 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-privileges so setuid binaries cannot raise privileges.
  • --read-only for 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 runc on 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.

Kubernetes — Pod Security Standards

"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 forbiddenthe policy's wordingwhere in the manifest
privileged PodsPrivileged Pods disable most security mechanisms and must be disallowed.securityContext.privileged
host namespacesSharing the host namespaces must be disallowed.spec.hostNetwork, spec.hostPID, spec.hostIPC
extra capabilitiesAdding additional capabilities beyond those listed below must be disallowed.securityContext.capabilities.add
turning seccomp offSeccomp profile must not be explicitly set to Unconfined.securityContext.seccompProfile.type
hostPath volumesHostPath 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 addedthe policy's wording
no privilege escalationPrivilege escalation (such as via set-user-ID or set-group-ID file mode) should not be allowed.
run as non-rootContainers must be required to run as non-root users.
and not as UID 0 explicitlyContainers must not set runAsUser to 0
seccomp mandatorySeccomp 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 droppedContainers 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

Kubernetes API — core/v1 SecurityContext

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.

Ibid.

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.

Kubernetes — RuntimeClass

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.

Common misconceptions

Claim

“A container isolates just like a virtual machine.”

Actually

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.

Claim

“A container escape is always a hard kernel exploit.”

Actually

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.

Claim

“Adding a user to the docker group is just convenient.”

Actually

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.

Claim

“Patch runc and container escapes are closed.”

Actually

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.

Claim

“Since I'm root inside the container, I'm almost on the host.”

Actually

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

Question 1 of 4

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

  1. 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/
  2. 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
  3. 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/
  4. 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
  5. 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
  6. 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
  7. 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/