CoursesRuntime & eBPF securityContainer escape paths

Container escape paths

Privileged, hostPath, sockets, caps, runtime CVEs.

Expert35 min · lesson 13 of 15

A container escape sounds like a movie plot: the attacker melts through the kernel and lands on the host. Real life is duller. Most of the time it is a loading dock with the door propped open. A privileged pod, a hostPath mount (a Kubernetes volume that maps a directory from the node straight into the container), a mounted container runtime socket, an added CAP_SYS_ADMIN capability, and the host belongs to whoever asked for it. Admission policy could have refused every one of those.

A few terms, in plain words. A container escape means code inside a container reaching the host, or the other containers sharing that host. Admission is the gate Kubernetes runs before a pod is allowed to exist at all. The container runtime is the low-level software that actually starts containers, usually containerd and runc, and it lives on the node rather than in your image. The plan has three parts: forbid the dangerous patterns at admission, patch runtime bugs fast, and reach for gVisor or Kata when a workload is untrusted enough to be worth the extra cost.

The settings that hand over the host

Start with a piece of paperwork nobody enjoys: write down what your cloud provider's nodes trust. Some managed platforms expose extra metadata services, small HTTP endpoints reachable from the node that will hand credentials to anything able to talk to them. Your NetworkPolicy and your iptables or eBPF (extended Berkeley Packet Filter, the in-kernel programmable filter that Cilium and Tetragon build on) egress rules have to cover those addresses too. A pod that never touches the kernel can still walk off with node credentials.

Supply chain belongs in this conversation as well. A malicious image that expects to run privileged is still a win for the attacker if your admission gate lets it through. Scanning images and admitting pods are cousins. Practice the checks on staging until they feel boring. Boring is reliable.

Now the list. privileged: true, a mount of /var/run/docker.sock or the containerd socket, a hostPath of / or of the kubelet directories, hostPID, hostNetwork, and an added CAP_SYS_ADMIN (the Linux capability carrying most of the kernel's administrative powers). Each one switches off an isolation primitive the container was counting on. Pod Security Admission, the gate built into Kubernetes itself, rejects most of them at its restricted level. Plenty of clusters still keep exceptions alive in a build namespace, and those manifests get copied into production by somebody in a hurry.

forbidden manifest patterns
securityContext: { privileged: true }
volumes:
- hostPath: { path: /var/run/docker.sock }
hostPID: true
capabilities: { add: ["SYS_ADMIN"] }

When the bug is in the boundary itself

Sometimes the software drawing the boundary is the thing that breaks. runc CVE-2019-5736 let a container overwrite the host's runc binary, so the next container start ran the attacker's code as root on the node. Leaky Vessels (CVE-2024-21626) got out through a crafted working directory and procfs, the /proc filesystem the kernel exposes for running processes. Patch runtimes promptly. Real escape chains tend to combine one of these bugs with a small misconfiguration, and because every container on a node shares one kernel, a kernel privilege escalation reaches all of them at once.

Heavier isolation, and when to spend it

Two options buy you a thicker wall. gVisor puts a user-space kernel in front of the real one and answers syscalls itself. Kata runs each pod inside a small virtual machine. Both shrink the shared kernel surface an escape needs, which is what you want for multi-tenant clusters or anything running code you did not write. Neither is a sensible default for every app. Escalate by sensitivity, and start by checking which handlers your cluster already offers.

terminal
kubectl get runtimeclass
# example output:
NAME HANDLER AGE
gvisor gvisor 90d
kata kata 90d

Admission is the cheap place to stop this

Kyverno and Gatekeeper are policy engines that inspect a pod before the scheduler ever sees it. Point them at production namespaces and have them reject privileged pods, hostPath volumes outside a short allowlist, and added capabilities. Lint manifests in CI before merge as well, so the argument happens in a pull request instead of at 2am. An escape you stop at deploy time costs you a code review. One you catch at runtime costs you an incident.

PSA restricted label
pod-security.kubernetes.io/enforce: restricted

Patch the boundary, then prove the patch

Track containerd, runc and kernel versions on every node. Scanners that read node packages complement image scanning, because your image can be spotless while the runtime underneath it is a year behind. After you patch a CVE, replay the escape proof of concept in a lab and confirm that it now fails. A patch you never tested is a hope, not a control.

terminal
containerd --version
# example output:
containerd github.com/containerd/containerd v1.7.18

Rehearse the escape before someone else tries it

Once a year, have someone attempt the documented misconfiguration escapes on a lab cluster: a privileged pod, a mounted runtime socket, an added CAP_SYS_ADMIN. Then try the same three against a clone of production and watch admission refuse them. If your purple team only exercises web application bugs, your container isolation story is still fiction. When the red team does get through in the lab, do not close the exercise with a report. Close it with a Gatekeeper constraint and a Falco rule at Critical severity. Track the time from CVE publication to a patched runtime on nodes as its own number too, separate from your image patching target, because node patching is slower and likes to hide behind the image metric.

terminal
kubectl auth can-i create pods --as=system:serviceaccount:build:default -n prod
# example output:
no

A service account from the build namespace has no business creating pods in production, and that no is the answer you want to see. RBAC (role-based access control, the Kubernetes permission system) keeps a stolen CI token stuck in the build tier instead of letting it reach prod.

Field notes from real clusters

The boring escape wins. A CI job needed Docker inside Docker, so somebody mounted the runtime socket into the build pod. The same service account could also deploy to production. Phishing that account was the entire attack. No kernel exploit, no CVE, nothing worth a conference talk. When you size up escape risk in your own cluster, count socket mounts and privileged pods before you count CVE entries.

Knowing your versions is part of the same hygiene. Put containerd --version, runc --version and the kernel string from /proc/version into inventory, collected automatically rather than by hand. When a runc CVE drops you want the blast radius in minutes. A team that needs a week of SSH archaeology to answer 'which nodes' is not ready for emergency patching.

Runtime detection still has a job here, covering what admission cannot foresee. Handle what it gives you carefully. A rule that fires when a process opens the runtime socket, or when a fresh mount of the host root shows up inside a running container, is a signal to investigate. It is not proof that someone owns your node. Backup agents and debug sidecars do ugly legitimate things all day. Work every one of those alerts, and write down which ones turned out to be normal so the next person on call does not start from zero. Watching more syscalls produces more of these alerts and costs real CPU on every node, so instrument the behavior tied to escapes rather than everything the kernel will tell you.

A common misread: distroless images plus a non-root user means you cannot escape. Both help. Neither saves you when the pod runs privileged, or when runc sits on a kernel with an open bug. The opposite mistake is skipping distroless because it is not sufficient on its own. It still strips out the shell and package tools an attacker hopes to find. Stack the dull controls instead of picking a favorite and stopping there.

gVisor and Kata sell isolation and charge for it in compatibility work and, for syscall-heavy workloads, in latency you will notice. Spend that budget on multi-tenant clusters and untrusted plugin code first. Force Kata under every internal Go service and you will burn the goodwill you need on the day a genuinely risky workload arrives.

Keep a live count of escape enablers: privileged pods, runtime socket mounts, hostPath volumes pointing at sensitive paths, containers adding CAP_SYS_ADMIN. Chart it over weeks. Celebrate when it drops. Treat a spike the way you treat an availability regression and go find the migration behind it, because exceptions pile up in the week after any big platform change.

Your patch cadence needs an emergency lane. Write down how fast you can roll a new node image when a critical runc or kernel CVE lands, then rehearse that path once a year on a routine patch. If today's honest answer is 'we would figure it out', you have already figured out that you will be late.

Mounting the runtime socket hands over root
A container with /var/run/docker.sock or the containerd socket mounted can ask the runtime to start another container with the whole host filesystem attached. That is root on the node, with no exploit involved. Never mount runtime sockets into workloads. Give build jobs a scoped build API instead.
How containers escape
misconfiguration
privileged / hostPath / socket
host handed over
dangerous caps / host NS
isolation reopened
boundary bugs
runtime CVEs
runc breakouts
kernel vulns
shared surface
stronger isolation
gVisor / Kata
shrink shared kernel
Forbid the enablers, patch the boundary, isolate untrusted code.

Every node in that diagram is something you can count today. Privileged pods, hostPath volumes and writable kernel module access are features somebody turned on, and they sit in manifests you already keep in git. Inventory them in the repository and in CI the same way you inventory CVEs, because the attacker only needs one that stayed open.

Try this

Run this on a lab cluster or a single staging node. Read the output before you touch anything. One look is not grounds for changing production policy.

terminal
$ kubectl get pod privileged-demo -o jsonpath='{.spec.containers[0].securityContext.privileged}{"\n"}'
true
$ kubectl auth can-i create pods --as=system:serviceaccount:default:builder
yes
$ kubectl get psa -A 2>/dev/null | head -3
NAMESPACE ENFORCE WARN
payments restricted restricted

Takeaway

Remember: escapes are mostly misconfiguration, with the occasional runtime CVE behind them. Admission closes the misconfigurations, a patch train closes the CVEs, and reading versions off real nodes tells you whether either one is actually happening.

Next step: pick one real privileged exception in your cluster and either clear it or put a date on its removal. Then move on to escape detection, so the exceptions you cannot kill yet still ring a bell.

Quick check
01In real enterprise clusters, which of these enables the most container escapes?
Incorrect — Zero-days exist and they are rare. The common path is a setting somebody turned on.
Correct — Each one switches off an isolation primitive on purpose, and admission can refuse all three.
Incorrect — Missing detection means you find out late. It does not open the escape path in the first place.
Incorrect — RuntimeDefault restricts which syscalls a container may make, so it shrinks escape surface rather than creating it.
02Why patch runc and containerd quickly once an escape CVE lands?
Incorrect — They affect the software that starts and isolates every container on the node, not your scanning tools.
Correct — CVE-2019-5736 overwrote the host runc binary and Leaky Vessels used a crafted working directory and procfs, so a well-behaved pod spec does not save you.
Incorrect — Nothing patches the node runtime on your behalf. You roll node images or node packages yourself.
Incorrect — NetworkPolicy filters traffic. It has no say in how runc handles a working directory.
03On a staging cluster your checks return privileged true for pod privileged-demo, yes for whether the default:builder service account can create pods, and a restricted enforce label only on the payments namespace. What do you do next?
Incorrect — That label enforces on the namespace it is set on. privileged-demo is somewhere else, still privileged.
Incorrect — Detection covers what admission cannot foresee. Admission can foresee this one, and stopping it at admit time is cheaper than an incident.
Correct — This is the boring escape chain from the field notes: a privileged pod plus a service account that can deploy. The RBAC check should come back no for prod.
Incorrect — Heavier isolation is for untrusted workloads and does not undo privileged. Fix the setting first and escalate isolation by sensitivity.

Related