Safeguard
Container Security

Container-handling security fundamentals: immutability, signing, and privilege drops

Two runc CVEs, five years apart, both turned root-in-container into root-on-host — proof that container isolation needs backup, not blind trust.

Safeguard Research Team
Research
7 min read

On February 11, 2019, the Kubernetes security team disclosed CVE-2019-5736, a runc flaw that let a malicious container overwrite the host's own runc binary via /proc/self/exe and gain root on the underlying host the next time any container was started or attached to. Nearly five years later, on January 31, 2024, a second runc flaw — CVE-2024-21626, nicknamed "Leaky Vessels" — did the same thing through a different door: runc versions up to 1.1.11 could leak an internal file descriptor, including one pointing at the host's /sys/fs/cgroup, and a malicious image that set its working directory to that leaked path could break out to the host filesystem. Both bugs were fixed at the container runtime layer (a runc release adding non-dumpable process handling right after 1.0-rc6 for the first, runc 1.1.12 plus containerd 1.6.28/1.7.13 and Docker 25.0.2 for the second), and both prove the same point: a Linux namespace and cgroup boundary is not, by itself, a security perimeter. This piece covers the three practical defenses that hold up even when the runtime itself has a bug — image immutability, cryptographic signing, and dropping privileges at runtime — and why none of them are optional extras.

What actually happened in CVE-2019-5736?

CVE-2019-5736 exploited how runc exec and runc run briefly hold a file descriptor open to their own binary on the host while starting a container process. A container with root access inside its namespace could replace its own /proc/self/exe target with a malicious binary and trick a host process — anything that later executed runc against that container, such as an operator running docker exec — into overwriting the real runc binary on the host with attacker-controlled code. The next invocation of runc anywhere on that host then ran the attacker's payload as root. AWS's public writeup, "Anatomy of CVE-2019-5736," and Palo Alto Networks' Unit 42 both confirmed the flaw affected any consumer of runc at default settings, including Docker and CRI-O, not just Kubernetes directly. The fix made the runc process non-dumpable and cloned a protected copy of itself before executing, closing the overwrite window. The practical lesson: root inside a container is one runtime bug away from root on the node, which is exactly why non-root containers matter even when your runtime is patched.

What made "Leaky Vessels" different, and why did it matter more?

CVE-2024-21626 was arguably worse in some deployments because it required less: instead of tricking an operator into re-running runc, a malicious container image could trigger the escape on its own during runc run, simply by setting its working directory (process.cwd in the OCI runtime spec) to a path built from a file descriptor runc had accidentally leaked — in the worst case, one pointing at the host's cgroup filesystem. That meant a hostile Dockerfile or a compromised base image pulled from a public registry could attempt host escape the moment it was run, with no operator interaction. The GitHub Security Advisory (GHSA-xr7r-f8xq-vfvv) and follow-up analysis from Snyk and Palo Alto Networks both flagged the same mitigation: upgrade to runc 1.1.12, containerd 1.6.28 or 1.7.13, and Docker 25.0.2, all released within days of disclosure. The broader point stands out clearly here — an attacker doesn't need a privileged flag or a misconfigured volume mount to reach the host; a runtime-level bug in the isolation layer itself is enough, which is why defenses that don't depend on the runtime being bug-free are the ones worth building around.

Why does image immutability matter if the base image gets patched anyway?

Immutability closes a gap that patching alone leaves open: the gap between what you tested and what actually runs in production. A mutable tag like node:20-alpine can point to a different set of bytes tomorrow than it does today, so two nodes pulling "the same image" during a rolling deploy can silently run different code — and an attacker who compromises a registry or a build pipeline can push a backdoored image under a tag your cluster already trusts, with no version bump to alert anyone. Pinning deployments to an immutable digest (@sha256:...) rather than a mutable tag guarantees the bytes that passed your scan are the exact bytes that run. This is standard guidance in NIST SP 800-190, the Application Container Security Guide, which treats image integrity as a baseline control rather than a hardening extra. Digest pinning also makes patch response auditable: a rebuild produces a new digest, old digests stay retrievable for rollback, and nothing about "which image is running" is ever a guess.

How does signing stop a tampered or unauthorized image from ever running?

Signing turns "we trust this registry" into "we can cryptographically prove this exact artifact came from this exact build." Sigstore's keyless signing model — Fulcio issuing short-lived certificates bound to a workload's OIDC identity (a GitHub Actions run, a GitLab pipeline), and Rekor recording every signature in a public, append-only transparency log — removes the long-lived private key that has historically been the weak point in code-signing schemes: there's no key to steal because none persists past the build. The control only has teeth if it's enforced at admission, not just recorded: a Kubernetes admission controller that checks signature validity, SBOM presence, and SLSA provenance level before a pod is scheduled turns "this image is unsigned" into a blocked deployment instead of a Slack alert after the fact. Safeguard's attestation pipeline follows this pattern directly — it signs SLSA provenance, SBOM, and vulnerability-scan predicates via Sigstore at build time and verifies signer identity and SLSA level (defaulting to Level 3) at admission, so a tampered or unsigned image fails to start rather than failing to be noticed.

Why drop privileges at runtime if the image is already signed and immutable?

Because signing and immutability protect the supply chain up to the moment a container starts — they say nothing about what that container can do once it's running, and CVE-2019-5736 and CVE-2024-21626 both show a fully legitimate, correctly signed image can still exploit a runtime bug from the inside if it's allowed to run as root with a writable filesystem and a full capability set. Runtime privilege dropping is the layer that limits the blast radius when a runtime bug or a compromised dependency does get exploited: setting runAsNonRoot: true in a Kubernetes securityContext removes the "root inside the container" precondition both runc CVEs needed to reach host root; readOnlyRootFilesystem: true denies the write access an escape or a dropped payload typically needs; and capabilities: drop: ["ALL"], adding back only what's genuinely required, closes off capabilities like CAP_SYS_ADMIN that turn a contained process into one with real host-level reach. None of these controls prevent a runtime bug from existing — but together they mean a runtime bug alone isn't enough to finish the job.

How Safeguard helps

Safeguard treats these three controls as one connected lifecycle rather than three separate checklists. Every build produces a signed SLSA provenance and CycloneDX SBOM attestation via Sigstore, and the Kubernetes admission controller verifies signer identity, SBOM presence, and SLSA level before any pod — including third-party images — is allowed to run, so an unsigned or tampered image is denied at the cluster boundary rather than caught after the fact. When a new CVE lands on a base image or a dependency baked into a running container, self-healing rebuilds against a pinned digest, runs your existing test suite, and promotes under a new immutable tag with median customer time-to-heal of 20-45 minutes from CVE publication to production rollout — closing the immutability loop without waiting on a manual PR chain. Combined with policy-enforced non-root, read-only-filesystem, and dropped-capability baselines at admission, the result is a pipeline where a runtime-level bug like CVE-2024-21626 has fewer places left to land.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.