Docker & ContainersXI · Container & Host SecurityDevices and binds
Device access and bind mount risks
What you'll learn
- Audit device access and bind mounts for security impact
- Replace insecure patterns with safer alternatives
- Recognise the kill chain of bind-mount escape
- Explain what --device grants beyond a node in /dev
Prerequisites
None — start here.
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-09
Bind mounts and device access are two of the most common sources of container compromise. A single bind mount of the wrong host path turns an application-level vulnerability into a host compromise, and it does so without needing a kernel bug, a container escape, or any Docker defect at all — you handed the container the path.
They are two different mechanisms with two different blast radii, so this lesson treats them separately before putting them back together in an audit.
The kill chain
sequenceDiagram
participant Attacker as Attacker
participant App as Vulnerable app container
participant Host as Host
Note over Attacker,App: 1. Attacker exploits app vulnerability
Attacker->>App: RCE in app
Note over App,Host: 2. App reaches a host resource through a mount
Attacker->>Host: read or write the mounted path
Note over Host,Attacker: 3. That path grants more than it looks like
Attacker->>Host: persist, or pivot to the daemon
Note over Host: 4. Host root
Attacker->>Host: exfiltrate data
The chain is always four steps and only step 2 is under your control. Step 1 will happen eventually; that is what “vulnerability” means. Step 3 is a property of the path you mounted. Your entire leverage is in choosing what step 2 can reach.
The dangerous bind mounts, and what each one actually buys
| Mount | What the attacker gets, concretely |
|---|---|
/var/run/docker.sock | The daemon API. Create a container with the host root bind-mounted, and you are root on the host. See the Docker socket lesson. |
/ | Everything below. Write an SSH key into /root/.ssh/authorized_keys, or a unit file into /etc/systemd/system, and wait. |
/etc | Persistence and credentials: a file in /etc/cron.d, a line appended to /etc/passwd, the host’s /etc/shadow for offline cracking. |
/proc | /proc/1/root is the host root filesystem, reachable without mounting /. /proc/sys is writable kernel tuning. |
/sys | /sys/kernel/uevent_helper and, on cgroup v1 hosts, a cgroup release_agent — both are “run this host binary as root” primitives. |
/dev | Every block device on the host, including the one holding /. |
/var/lib/docker | Every other container’s writable layer and every image layer. Modify a peer container’s filesystem offline. |
/var/lib/kubelet | Kubernetes service-account tokens for every pod on the node. |
| Host SSH keys | Lateral movement to every host that trusts them. |
~/.aws/credentials, ~/.kube/config | The cloud account or the cluster, not just the host. |
The rule: bind-mount the smallest path the workload needs, with the
least access it requires. “The config directory” is a reasonable
request. “The config directory is /etc” is not.
What --device actually grants
--device /dev/sda:/dev/sda does two things, and the second is the
one people miss.
- It creates the device node inside the container’s
/dev. - It installs a device cgroup rule permitting access to that device’s major:minor number.
Without the second, the node would exist and every open() would
return EPERM, because Docker denies all device access by default
apart from a small standard set (/dev/null, zero, full,
random, urandom, tty, console, ptmx).
The default permissions are rwm — read, write, and mknod. You
can narrow them with a third field:
docker run --rm --device=/dev/sda:/dev/sda:r debian:12 blockdev --getsize64 /dev/sdaGranting a raw block device is a bigger decision than granting a
directory on it. A process that can read /dev/sda can read
every file on every filesystem on that disk, regardless of file
permissions, ownership, or which containers those files belong to —
DAC checks live in the filesystem layer, and reading the block
device bypasses the filesystem entirely. A process that can
write it can plant a setuid-root binary, or corrupt the
superblock, on a filesystem the host has mounted.
So --device=/dev/sda is not “scoped access”. It is the whole disk.
--device=/dev/dri/renderD128 for GPU compute, or
--device=/dev/ttyUSB0 for a serial peripheral, is scoped access —
those devices grant what they say on the label.
CID=media
docker inspect "$CID" --format '{{json .HostConfig.Devices}}'
docker exec "$CID" sh -c 'ls -l /dev'Safe patterns
# One config file, read-only, not the directory it lives in
docker run -v /srv/myapp/config.yaml:/etc/myapp/config.yaml:ro my-app
# A named volume instead of a host path: no host filesystem is exposed
docker run -v myapp-data:/var/lib/myapp my-app
# One specific device, not /dev
docker run --device /dev/dri/renderD128:/dev/dri/renderD128 my-app
# --mount refuses to create a missing source; -v silently creates it
docker run --mount type=bind,src=/srv/myapp/conf,dst=/etc/myapp,readonly my-appThat last one is worth its own sentence. -v /srv/myapp/conf:/etc/myapp
with a typo in the host path does not fail — Docker creates
/srv/myapp/conf on the host as an empty root-owned directory and
mounts it. The container starts, sees no config, falls back to
defaults, and you spend an hour looking at the application.
--mount type=bind returns an error instead. Prefer it in anything
automated.
Auditing what is mounted today
for c in $(docker ps -q); do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d /)
echo "=== $name"
docker inspect --format '{{json .Mounts}}' "$c" \
| jq -r '.[] | " \(.Type)\t\(.Source) -> \(.Destination)\t\(if .RW then "rw" else "ro" end)"'
done$ docker ps -q | xargs -r -I{} docker inspect --format '{{.Name}} {{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}' {} | grep -E 'docker.sock|/etc:|/proc:|/sys:|/:'/ci-runner /var/run/docker.sock:/var/run/docker.sock
/backup /:/host
/node-exporter /proc:/host/proc /sys:/host/sys
/api /srv/api/conf:/etc/apiIllustrative output
node-exporter is the exception that proves the rule: it genuinely
needs /proc and /sys, read-only, and that is a documented,
approved exception. ci-runner and backup are findings.
/srv/api/conf:/etc/api matched the grep on the destination and is
fine — which is why an audit produces a list a human reads, not a
number.
For each mount, ask:
- Does the workload need this exact path, or the parent of it?
- Does it need write access, or would
:rodo? - Could a named volume replace it entirely?
- Is the source sensitive — credentials, sockets, system directories, another container’s data?
- If an attacker had RCE in this container right now, what would this mount give them?
Knowledge check
Knowledge check · 5 questions
Q1. `--device /dev/sda:/dev/sda` gives the container:
Q2. On a cgroup v2 host, where is a container device access policy stored?
Q3. Which statements about `:ro` on a bind mount are correct? Select all that apply.
Q4. If the host path in a `-v` bind mount does not exist, Docker creates it as an empty root-owned directory and starts the container anyway.
Q5. Name the mount propagation mode that lets a container create mounts visible in the host mount namespace.
Passing score: 75%. Answers are checked in this browser.