The big three: privileged, docker.sock, hostPath
The flags and mounts that hand over the host.
Most container escapes that make the news never touch a kernel bug. They walk through a door someone left open. Three doors, really: the --privileged flag, a mounted Docker socket, and a host directory bind-mounted into the container. Each is something you grant on the command line, which is the encouraging part. What you hand over, you can refuse. This lesson walks each one from the attacker's side into root on the host, then shows how to spot it in a running container and the scoped substitute that closes it without breaking the workload.
1. --privileged: the whole keyring, alarms off
Start with how a normal container is boxed in, because --privileged is defined by everything it removes. Linux splits root's power into about 40 separate keys called capabilities (a cap for short). CAP_NET_ADMIN lets a process reconfigure networking. CAP_SYS_MODULE lets it load kernel code. CAP_SYS_ADMIN is a grab-bag that includes mounting filesystems. A default Docker container gets a small, safe handful of these keys and the rest stay locked out of reach. Layered on top are two more controls: seccomp (secure computing mode, a bouncer holding an allow-list of permitted system calls) and an AppArmor profile (a Linux Security Module, or LSM, that limits which files and operations the process can touch).
--privileged cancels all of that in one move. The full capability set comes back, seccomp switches to unconfined, the AppArmor profile is dropped, and every host device under /dev becomes readable and writable from inside. It's the whole keyring handed back with the alarm system switched off. An attacker who lands in a privileged container mounts the host's root disk and reads /etc/shadow, or loads a malicious kernel module and owns the box outright. Calling it a root shell on the host with a couple of extra steps is not an exaggeration.
# a privileged container gets the full capability set and the host's disks$ docker run --rm --privileged alpine sh -c \'grep CapEff /proc/self/status; ls /dev/sda* 2>/dev/null; echo "--- host disks above"'CapEff: 000001ffffffffff # every capability set (41 bits all on)/dev/sda /dev/sda1--- host disks above# mounting /dev/sda1 now exposes the host root filesystem, /etc/shadow included.
# DETECT: is anything running privileged right now?$ docker inspect -f '{{.Name}} privileged={{.HostConfig.Privileged}}' $(docker ps -q)/ci-runner privileged=true # FINDING: flag this in review/web privileged=false# a default (non-privileged) container's caps, for comparison:$ docker run --rm alpine grep CapEff /proc/self/statusCapEff: 00000000a80425fb # a small subset, not the full 0x1ffffffffff# FIX: grant the one capability + one device the workload actually needs$ docker run --rm --cap-drop ALL --cap-add NET_ADMIN --device /dev/net/tun myvpn:1.0
2. A mounted docker.sock: a phone line to root
The Docker socket at /var/run/docker.sock is how the docker command talks to the daemon, and the daemon runs as root on the host. Every command you type turns into a request sent across that socket to the daemon's API (application programming interface). Treat the socket as a direct phone line to a butler with root privileges who will build, run, mount, and delete whatever a caller asks for. Bind-mount that socket into a container and the container is now holding the phone.
This pattern shows up constantly in continuous integration (CI) pipelines and in "Docker-in-Docker" shortcuts, where a build step wants to run docker build. The socket is the quick fix, so people reach for it. The problem is that a process which can reach the daemon can ask it to start a second container bind-mounting the host root, then read or overwrite anything on the machine. The first container never broke out of its namespaces, the walls that box in what a process is allowed to see. It didn't have to. It asked the daemon to hand over the host, and the daemon obliged. That's why cap-drop, a read-only root filesystem, and seccomp on the first container buy you nothing here: the escape runs in the daemon's context, not the container's.
# a container holding only the socket asks the daemon to start ANOTHER container# that bind-mounts the host root, then reads the host's password hashes$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli \docker run --rm -v /:/host alpine cat /host/etc/shadow | head -2root:$6$Xn8f2/qA$0K7...:19913:0:99999:7:::daemon:*:19875:0:99999:7:::# the outer container had no caps and no privileged flag. it still owns the host.
# DETECT: which running containers have the socket bind-mounted?$ docker ps -q | xargs docker inspect -f \'{{.Name}}{{range .Mounts}}{{if eq .Source "/var/run/docker.sock"}} <- docker.sock MOUNTED{{end}}{{end}}'/ci-runner <- docker.sock MOUNTED # FINDING/web# FIX: never mount it into app/CI containers. if a tool genuinely needs# limited API access, front the socket with a proxy and block writes.$ docker run -d --name dockerproxy \-v /var/run/docker.sock:/var/run/docker.sock:ro \-e CONTAINERS=1 -e IMAGES=1 -e POST=0 \-p 127.0.0.1:2375:2375 tecnativa/docker-socket-proxy:latest# POST=0 blocks container-create, so the create call in the escape returns 403 Forbidden.# In CI, prefer a rootless builder (BuildKit rootless, Kaniko) so no socket exists at all.
3. hostPath: leaking the host one directory at a time
A bind mount is a window cut from the container into the host filesystem, and the danger scales with what you frame in it. -v /:/host is the obvious catastrophe: the entire host, usually writable, sitting inside the container. The quieter ones do just as much damage. Host /etc carries credentials and cron schedules. /root/.ssh carries private keys. /proc and /sys are live kernel interfaces you can poke. And any directory the host later executes, a cron drop-in or a systemd unit file, turns write access into code execution: leave a file, wait for the host to run it as root. Often you don't even need to write. Reading the host's secrets straight through the mount is the whole prize, and that needs no privileged flag and no socket at all.
# ESCAPE: no privileged, no socket, just a host-root bind mount$ docker run --rm -v /:/host alpine cat /host/etc/shadow | head -1root:$6$Xn8f2/qA$0K7...:19913:0:99999:7:::# DETECT: list every bind mount and whether it is writable$ docker inspect -f '{{range .Mounts}}{{.Source}} -> {{.Destination}} ({{if .RW}}rw{{else}}ro{{end}}){{"\n"}}{{end}}' web/ -> /host (rw) # FINDING: entire host root, writable# FIX: mount only the one path the app needs, read-only$ docker run --rm -v /var/lib/app/data:/data:ro myapp:1.0
-v /var/run/docker.sock:/var/run/docker.sock:ro, and assuming that neuters it. It does not. The :ro only stops the container from deleting or overwriting the socket file. The Docker API still answers every request that arrives over it, including create-a-privileged-container. Read-only on a Unix socket controls the file, not the protocol spoken across it. The escape earlier works fine against a :ro socket. If a tool genuinely needs API access, the only real limit is a proxy that allow-lists specific API paths and sets POST=0.Three settings cause a disproportionate share of container-to-host takeovers: --privileged, a mounted docker.sock, and sensitive hostPath or bind mounts. Each one has a narrower alternative that does the same job. Grant the single capability the workload actually needs. Put a narrow API proxy in front of the daemon instead of handing over the socket. Mount the one directory required, read-only.
Admission policies (OPA/Gatekeeper, Kyverno, Docker equivalents in CI) should deny the big three by default on shared clusters.
If you must break glass, time-box the exception and alert when it appears outside the break-glass namespace.
Run the same check after every change window. Confirm the control is still on, paste the command and its output into the ticket, and refuse to close the change if the reading drifted. Always prefer the tightest scope the workload will tolerate. That habit compounds across every host and every pipeline you own.
Try this
In a lab only, show how mounting docker.sock lets a container start a sibling that bind-mounts host /. Then remove it and note the safer pattern (no socket).
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli \docker run --rm -v /:/host alpine cat /host/etc/hostnamelab-host$ # that hostname read is host root via the API — never grant sock to untrusted workloads$ echo 'prefer: no socket mount; use a tightly scoped CI builder instead'
Takeaway
Refuse privileged, refuse docker.sock, refuse broad host mounts unless a reviewed exception says otherwise. Most "escapes" are just these three doors left open.
--privileged 'the whole keyring handed back with the alarm system switched off.' Which set of protections does it strip in one move?--cap-drop ALL and no --privileged, but bind-mounts the host's /etc into the container (read-write, the default). A reviewer calls it low risk because the container itself is hardened. What's the actual exposure?