CoursesAdvanced container securityThe big three: privileged, docker.sock, hostPath

The big three: privileged, docker.sock, hostPath

The flags and mounts that hand over the host.

Advanced16 min · lesson 23 of 25

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.

escape — privileged
# 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 + fix — privileged
# 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/status
CapEff: 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.

escape — docker.sock (runnable, one line)
# 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 -2
root:$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 + fix — docker.sock
# 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 + detect + fix — host mount
# ESCAPE: no privileged, no socket, just a host-root bind mount
$ docker run --rm -v /:/host alpine cat /host/etc/shadow | head -1
root:$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
Someone asks for one of the big three. What do you grant?
A workload requests --privileged, the Docker socket, or a host mount
Do not grant the whole host. Grant the scoped substitute.
"it needs --privileged"
One --cap-add plus one --device
e.g. --cap-drop ALL --cap-add NET_ADMIN --device /dev/net/tun, never the full set
"it needs docker.sock"
Socket-proxy with POST=0, or a rootless builder
allow-list read-only API paths; BuildKit-rootless / Kaniko need no socket
"it needs a host mount"
One narrow path, read-only
-v /var/lib/app/data:/data:ro, never /, /etc, /proc, /sys, /root/.ssh
"it truly needs all of it"
Isolate the blast radius
dedicated node plus a sandbox runtime (gVisor/Kata); treat it as host-equivalent trust
In a manifest review the default answer is no. Each branch swaps the whole-host grant for the specific thing the workload actually needs.
Mounting the socket :ro does not make it safe
A common mistake is bind-mounting the Docker socket read-only, -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).

terminal
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli \
docker run --rm -v /:/host alpine cat /host/etc/hostname
lab-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.

Quick check
01A container runs as a non-root user with --cap-drop ALL, --read-only, and the default seccomp profile. It also bind-mounts /var/run/docker.sock. Is it still a host-escape risk?
Incorrect — Those controls harden this container's own process, but the socket routes around all of them by handing work to the daemon.
Correct — The escape executes in the root daemon's context; the calling container's hardening never comes into play.
Incorrect — The Docker API grants full control over the socket regardless of the in-container UID (user ID).
Incorrect — Read-only stops writes to this container's filesystem; it does nothing to API calls sent over the socket.
02The lesson calls --privileged 'the whole keyring handed back with the alarm system switched off.' Which set of protections does it strip in one move?
Correct — privileged removes the entire confinement stack at once, which is why it equals a host root shell with a couple of steps.
Incorrect — privileged also switches seccomp to unconfined and drops the AppArmor profile.
Incorrect — it restores the full capability set and disables seccomp as well as exposing /dev.
Incorrect — privileged exposes host devices but does not bind-mount the socket or / for you; you still mount the disk yourself.
03A compose service runs as a non-root user with --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?
Incorrect — the mount routes around the container's own hardening entirely; those flags never touch it.
Incorrect — this understates it; the real prizes are host secrets and code execution, not just disk exhaustion.
Correct — /etc holds credentials and cron schedules, and any host-executed directory turns write access into root code execution.
Incorrect — a writable host bind-mount alone is enough; it needs no socket and no privileged flag.

Related