Skip to main content
RunBook Academy

Docker & ContainersXI · Container & Host SecurityDocker socket

The Docker socket — the most dangerous API on the host

Advanced⏱ ~28 min

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

Not yet marked complete on this device.

/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:

  1. Connects to the socket. No credential is needed. The API is plain HTTP over the unix socket; a single POST is all that is required. Nothing in Docker distinguishes this connection from the operator’s docker CLI.
  2. 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.
  3. 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.
  4. 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.d entry. 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: 0 is what makes this safe, not CONTAINERS: 1. CONTAINERS grants the /containers/ section of the API, which includes POST /containers/create. It is the method filter that reduces the section to listing and inspecting. Turn POST on and CONTAINERS: 1 becomes “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:2375 for local testing. In production the proxy should be reachable only on an internal: true Docker network. A 2375 on 0.0.0.0 is 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

Configuration changessh transport
docker context create \
  --docker host=ssh://docker-user@host1.example.com \
  --description="Remote engine" \
  my-remote-engine

docker context use my-remote-engine
docker info

The 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

Configuration changedaemon with mutual TLS
dockerd \
  --tlsverify \
  --tlscacert=/etc/docker/ca.pem \
  --tlscert=/etc/docker/server-cert.pem \
  --tlskey=/etc/docker/server-key.pem \
  -H=0.0.0.0:2376

Port 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

Read-only / Safefind every container with a daemon socket
$ 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.sock

Illustrative 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.

Read-only / Safewho else can reach the socket
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

  1. 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?

  2. Q2. In a tecnativa/docker-socket-proxy configuration, which single setting is doing most of the security work?

  3. Q3. Which of these are genuinely safer alternatives to bind-mounting the socket into a workload? Select all that apply.

  4. Q4. The Docker API applies per-endpoint authorisation once a client has connected to the socket.

  5. 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.