Docker & ContainersXI · Container & Host SecurityDocker socket
The Docker socket — the most dangerous API on the host
What you'll learn
- Explain why the Docker socket is host-equivalent
- Recognise legitimate use cases and alternatives
- Configure secure access to the Docker API
- Audit a host for socket exposure and prove the finding
Prerequisites
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
/var/run/docker.sock is the daemon’s control plane. It speaks HTTP
over a unix socket, it runs as root, and — this is the part that
matters — it does not authenticate or authorise anything. There
is no user model in the Docker API. If your process can connect()
to that socket, every API call succeeds. There is no read-only mode,
no scope, no token, no per-endpoint permission.
A container with that socket bind-mounted into it therefore has, in practice, the same power as root on the host.
The escalation path, in prose
It is worth walking through exactly how, because “equivalent to root” sounds like hyperbole until you see that it takes no vulnerability at all. Every step below is a documented, supported Docker API call being used exactly as designed.
An attacker with code execution inside a container that has the socket mounted:
- Connects to the socket. No credential is needed. The API is
plain HTTP over the unix socket; a single
POSTis all that is required. Nothing in Docker distinguishes this connection from the operator’sdockerCLI. - Asks the daemon to create a second container. The attacker
controls the entire container spec: image, command, mounts,
capabilities, namespaces. Note where this happens — in the
daemon, on the host, outside the first container’s
confinement. The first container’s
--cap-drop=ALL, seccomp profile, AppArmor profile, read-only root and non-root user are all irrelevant, because none of them apply to the container the daemon is about to create. - Specifies a spec that reaches the host. The obvious choice is the host root filesystem bind-mounted into the new container. The equally effective choices are host PID namespace, host network namespace, or full privilege — the API accepts all of them, and the daemon has no policy that would refuse.
- Starts it, and reads or writes the host. With the host root
mounted, that means writing an authorised key, a systemd unit, or
a
cron.dentry. With the host PID namespace, it means entering the namespaces of PID 1. Either way the process is running as host root, because the daemon runs as host root and creates its children accordingly.
No kernel bug. No container escape. No Docker defect. The container asked the daemon politely and the daemon, which has no concept of “who is asking”, complied.
The “legitimate” use cases
Some workloads genuinely need to talk to the daemon:
- CI/CD runners that build images or start service containers.
- Reverse proxies that discover containers by label (Traefik, Caddy’s Docker adapter).
- Monitoring agents that read container state.
- Log shippers that resolve container IDs to names.
- Deployment agents (Watchtower and similar) that pull and recreate.
These are real. “Never mount the socket” as a blanket rule breaks them, and a rule that breaks the workflow gets bypassed rather than followed. The useful framing is that every one of these needs strictly less than the full API, and the job is to give them only that.
Note the split: the proxies, monitors and log shippers need
GET only. The CI runners and deployment agents need to create
containers, which means they are host-equivalent by definition and
must be treated as trusted infrastructure — isolated hosts,
short-lived VMs, or a nested rootless daemon — not as sandboxed
workloads that happen to have a mount.
Alternative 1: a filtering socket proxy
The proxy sits between the workload and the socket and rejects
requests by HTTP method and API path. The widely used
implementation is tecnativa/docker-socket-proxy, an HAProxy with
an ACL set driven by environment variables.
services:
socket-proxy:
image: tecnativa/docker-socket-proxy:0.3.0
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
# POST defaults to 0. With it off, only GET and HEAD reach the
# daemon, which makes every enabled section read-only.
POST: 0
CONTAINERS: 1
# everything else stays at its revoked default
IMAGES: 0
NETWORKS: 0
VOLUMES: 0
EXEC: 0
SECRETS: 0
AUTH: 0
networks:
- proxy-net
# No port published to the host. Only the app network reaches it.
my-app:
image: myorg/myapp:1.0.0
environment:
DOCKER_HOST: tcp://socket-proxy:2375
networks:
- proxy-net
depends_on:
- socket-proxy
networks:
proxy-net:
internal: true
Two details carry the whole configuration:
POST: 0is what makes this safe, notCONTAINERS: 1.CONTAINERSgrants the/containers/section of the API, which includesPOST /containers/create. It is the method filter that reduces the section to listing and inspecting. TurnPOSTon andCONTAINERS: 1becomes “create any container you like”, which is where you started.- Do not publish the proxy’s port to the host. The example that
ships with the project maps
127.0.0.1:2375:2375for local testing. In production the proxy should be reachable only on aninternal: trueDocker network. A2375on0.0.0.0is an unauthenticated root API on the network, which is materially worse than the mount you were trying to remove.
EXEC: 1 deserves its own warning: it permits POST /containers/{id}/exec, which runs a command in an existing
container. Combined with any container on the host that is
privileged or has the host root mounted, that is a complete
escalation with POST restricted to one endpoint.
Alternative 2: don’t put the API on the host at all
docker context create \
--docker host=ssh://docker-user@host1.example.com \
--description="Remote engine" \
my-remote-engine
docker context use my-remote-engine
docker infoThe remote user still needs socket access on the far side, so this
does not reduce that user’s power — it moves the trust boundary to
SSH, where you have keys, authorized_keys restrictions, Match
blocks, revocation and an audit trail. That is a trust boundary you
can actually administer.
Alternative 3: TLS on TCP
dockerd \
--tlsverify \
--tlscacert=/etc/docker/ca.pem \
--tlscert=/etc/docker/server-cert.pem \
--tlskey=/etc/docker/server-key.pem \
-H=0.0.0.0:2376Port 2376 is TLS; 2375 is plaintext and must never be reachable by anything. Anyone holding a valid client certificate has root on that host, so treat certificate issuance with the same process as handing out root SSH keys, and set an expiry short enough that revocation is not the only mechanism you have.
Alternative 4: rootless
Under rootless Docker the socket lives at
$XDG_RUNTIME_DIR/docker.sock (typically /run/user/1000/docker.sock)
and the daemon runs as that unprivileged user. A container that
reaches this socket can still create containers — but they are
created by an unprivileged daemon, so “host root” is no longer at
the end of the chain. It is the strongest of the four alternatives
for the CI-runner case specifically.
Auditing
$ docker ps -q | xargs -r -I{} docker inspect --format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' {} | grep -E 'docker\.sock|containerd\.sock'/ci-runner /var/run/docker.sock
/traefik /var/run/docker.sock
/watchtower /var/run/docker.sockIllustrative output
Three findings with three different answers: traefik needs GET
only and belongs behind a proxy with POST: 0; watchtower needs
to create containers and is therefore host-equivalent infrastructure
that should not sit next to application workloads; ci-runner
should be on its own disposable host.
ls -l /var/run/docker.sock
SOCK_GRP=$(stat -c '%G' /var/run/docker.sock)
getent group "$SOCK_GRP"Membership of the docker group is not “permission to run
containers”. It is root, without sudo logging, without a password
prompt, and without appearing in /var/log/auth.log. Audit that
group with the same seriousness as sudoers.
Knowledge check
Knowledge check · 5 questions
Q1. A container has `/var/run/docker.sock` mounted and runs with `--cap-drop=ALL --read-only --security-opt no-new-privileges=true`. How much does that hardening reduce the risk?
Q2. In a tecnativa/docker-socket-proxy configuration, which single setting is doing most of the security work?
Q3. Which of these are genuinely safer alternatives to bind-mounting the socket into a workload? Select all that apply.
Q4. The Docker API applies per-endpoint authorisation once a client has connected to the socket.
Q5. Which TCP port should the daemon use when exposed with TLS, and which port must never be reachable?
Passing score: 75%. Answers are checked in this browser.