Container runtime security
A container is a process, not a VM — it shares the host kernel. Drop Linux capabilities, add a seccomp profile, and run read-only so one process bug stays a process bug instead of becoming a host compromise.
A Node service has an SSRF bug. Annoying, but contained — until you notice the container runs as --privileged because a year ago someone needed it to mount a FUSE filesystem for a one-off job and never reverted it. The attacker pivots from the SSRF to running code in the container, and now there is no wall left: --privileged hands the process every Linux capability, drops the seccomp filter, and exposes all host devices. They mknod a block device for the host root disk, mount it, and write a cron job onto the host. One web bug, owned the node, and from a node in a shared cluster you are one kubectl-token-scrape away from the whole cluster. The kernel was always shared; the only thing standing between a process bug and the host was the sandbox you turned off.
By the end of this lesson you’ll know why a container is a kernel-sharing process and not a tiny VM, and how three controls — dropped capabilities, a seccomp profile, and a read-only root filesystem — turn a contained bug into a contained bug instead of a host takeover.
A container is a process, not a VM
The single most important fact about container security is that a container is not a small virtual machine. It is an ordinary Linux process on the host, wrapped in namespaces (which change what it can see — its own PID tree, mounts, network) and cgroups (which limit what it can use — CPU, memory). What it crucially does not have is its own kernel. Every container on a node calls the same host kernel that everything else calls. A VM has a hypervisor and a guest kernel between the workload and the metal; a container has one shared kernel and some bookkeeping.
That changes the threat model completely. In a VM, escaping to the host means defeating the hypervisor — a hard, well-studied boundary. In a container, “escape” means getting the shared kernel to do something on your behalf that crosses the namespace fence: a kernel bug, an over-broad capability, a writable host path, a syscall that was never filtered. Container escape is the event where code inside a container gains code execution or file access on the host. It is not exotic — runc’s CVE-2019-5736 let a malicious image overwrite the host runc binary and execute as root on the host; CVE-2022-0492 abused a cgroups-v1 release-agent path to escape from a container with no exotic privileges at all. The boundary is real but thin, and your job at runtime is to make it thicker than the default.
Capabilities: stop running as a 40-power root
Classic UNIX has a binary: you are root (uid 0, can do everything) or you are not. Linux capabilities split root’s omnipotence into ~40 distinct privileges you can grant or drop individually — CAP_NET_BIND_SERVICE (bind ports below 1024), CAP_NET_RAW (craft raw packets, ping), CAP_SYS_ADMIN (a grab-bag so broad it’s been called “the new root”), CAP_SYS_PTRACE, CAP_DAC_OVERRIDE (bypass file permission checks), and so on.
Here is the trap: a container running as root is not the same as a container running as the host’s all-powerful root, but by default it still gets a generous default capability set. The reflex of a senior is drop ALL, then add back only what the workload provably needs — almost always nothing, because a typical web service binds a high port and reads files it owns. The capabilities that enable escape — CAP_SYS_ADMIN (mount, BPF, lots of escape paths), CAP_SYS_MODULE (load a kernel module = instant game over), CAP_DAC_READ_SEARCH (the CVE-2014-9357 / Docker “Shocker” file read), CAP_NET_ADMIN — are exactly the ones a normal app never uses. Dropping them costs you nothing and removes whole classes of escape. The anti-pattern is the inverse: --privileged, which doesn’t just grant all capabilities — it also disables seccomp/AppArmor and exposes host devices, which is why it appears in nearly every real container-escape writeup.
Seccomp: shrink the syscall attack surface
Capabilities gate privileged operations; seccomp (secure computing mode) gates the syscalls themselves. The Linux kernel exposes ~300–400 system calls, and that surface is the attack surface — every container kernel exploit is ultimately “call this syscall with these arguments.” A seccomp profile is an allowlist (or denylist) of syscalls applied to the process; a blocked syscall returns EPERM or kills the process instead of entering the kernel path.
The practical numbers matter. Docker’s default seccomp profile already blocks ~44 of the most dangerous syscalls — mount, ptrace on other processes, kexec_load (load a new kernel), bpf, keyctl, unshare/setns namespace tricks — and that single default has neutralized multiple real escape CVEs that depended on a now-blocked syscall. The senior failure mode to recognize: in Kubernetes, seccomp is not on by default for older clusters. A pod with no seccompProfile set historically ran unconfined — the full syscall surface exposed — which is why the modern reflex is to set seccompProfile: { type: RuntimeDefault } cluster-wide (and newer Kubernetes defaults to it). The cost is real but small: an overly tight custom profile can break a workload that legitimately needs an unusual syscall (some JITs, io_uring-heavy apps), so the move is RuntimeDefault as a baseline and a custom profile only when you’ve measured the syscalls the app actually makes.
Read-only root and the rest of the hardening set
The third pillar is the read-only root filesystem (readOnlyRootFilesystem: true). If the container’s filesystem is immutable, an attacker who gets code execution cannot drop a webshell, overwrite a binary on $PATH, modify a config to persist, or stage the tools they need for the next step. Most apps don’t write to their own image at all — they write to a database, a log sink, or a temp dir — so you mount a small emptyDir at /tmp for the genuinely ephemeral writes and freeze everything else. It converts “I have a shell” into “I have a shell that can’t keep anything,” which breaks persistence and most tooling-staging.
These controls are mutually reinforcing, and the table makes the mapping concrete: each one severs a different link in the escape chain, which is the whole point of defence in depth — a single bypass shouldn’t hand over the host.
| Control | What it constrains | Escape link it severs | Pod spec / flag |
|---|---|---|---|
| Drop capabilities | Privileged kernel operations (mount, BPF, modules) | Removes CAP_SYS_ADMIN/MODULE escape paths | capabilities.drop: ALL |
| Seccomp profile | Which of ~350 syscalls the process may call | Blocks the dangerous syscall an exploit needs | seccompProfile.type: RuntimeDefault |
| Read-only root FS | Writes to the container’s own filesystem | Stops webshell drop / binary overwrite / persistence | readOnlyRootFilesystem: true |
| Run as non-root | In-container uid 0 (limits damage if escaped) | Shrinks blast radius of a partial escape | runAsNonRoot: true |
| No privilege escalation | setuid binaries regaining privilege | Closes the setuid re-escalation path | allowPrivilegeEscalation: false |
▸Why this works
Why isn’t runAsNonRoot alone enough? Because non-root inside the container still shares the host kernel, and a kernel bug doesn’t care about your uid — a CVE in a syscall path can hand you host code execution from an unprivileged container user. Non-root is essential blast-radius reduction (it stops the trivial in-container privilege abuse and is required by most Pod Security Standards), but it is a layer, not the wall. The wall is built from capabilities + seccomp + read-only + non-root together, because each closes a path the others leave open. This is exactly why --privileged is so dangerous: it doesn’t weaken one layer, it removes several at once.
When hardening flags aren’t enough: stronger isolation
Sometimes the workload is genuinely untrusted — you’re running other people’s code (CI jobs, a multi-tenant function platform, AI-generated code in a sandbox). Hardening flags shrink the shared-kernel attack surface but don’t eliminate it; the kernel is still shared, so a sufficiently good kernel 0-day still escapes. For that tier the answer is a stronger isolation runtime: gVisor (a user-space kernel that intercepts syscalls so the container never touches the real kernel directly) or Kata Containers / Firecracker microVMs (a real lightweight guest kernel per workload, hypervisor-grade isolation with near-container startup). These cost some performance and operational complexity, so you don’t reach for them for your own first-party services — but for genuinely hostile code, “harden the shared kernel” is the wrong altitude and “don’t share the kernel” is the right one.
A first-party web service had an RCE. It runs as `--privileged` because a maintenance job once needed extra access. Pick the change that most reduces the chance an in-container RCE becomes a host compromise.
Why does a container escape threaten the host in a way a VM escape doesn't — and what makes `--privileged` uniquely dangerous?
Your Kubernetes pods set `runAsNonRoot: true` but nothing else. Why is that insufficient, and what's the senior baseline?
Order the container-escape chain that runtime hardening is designed to break, from the initial bug to full cluster compromise:
- 1 An app bug gives code execution inside the container
- 2 A broad/privileged capability set permits a dangerous operation
- 3 An unfiltered syscall (no seccomp) reaches a vulnerable kernel path
- 4 A writable host path lets the attacker write to the node
- 5 Host code execution → scrape a service-account token → pivot to the cluster
- 01Explain why a container is a different security boundary than a VM, and what 'container escape' concretely means.
- 02Walk through the three core runtime controls — capabilities, seccomp, read-only root — and what each one stops.
A container is not a small VM — it is a host process isolated by namespaces and cgroups, sharing the one host kernel with everything else on the node. That shared kernel is the whole reason container runtime security exists: a process bug, an over-broad capability, an unfiltered syscall, or a writable host path can cross the namespace fence and become host code execution — a container escape — and from a node in a shared cluster, a scraped service-account token turns one web bug into a cluster compromise. The defence is layered, and each layer severs a different link in that chain: drop ALL Linux capabilities and add back only what the workload provably needs (removing the CAP_SYS_ADMIN/CAP_SYS_MODULE paths escapes love); apply a RuntimeDefault seccomp profile so the ~350-syscall attack surface shrinks to what the app actually calls (and never run pods seccomp-unconfined); mount the root filesystem read-only so an attacker can’t persist or stage tools; and run non-root with allowPrivilegeEscalation: false to cap the blast radius. The cardinal sin is --privileged, which collapses several layers at once and shows up in nearly every real escape writeup. When the workload is genuinely untrusted, stop hardening the shared kernel and stop sharing it — reach for gVisor or Kata/Firecracker. Now when you read a pod spec, your first question is: does this securityContext drop capabilities, set a seccomp profile, and freeze the filesystem — or is it trusting a process bug to never happen?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.