Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXLI · Runner SecurityDockerSocket

The Docker socket risk — docker.sock mounted into a runner = root on the host; the escalation path

Advanced⏱ ~26 mingit

What you'll learn

  • Explain why /var/run/docker.sock mounted into a runner is host-equivalent root
  • Walk the escalation path from arbitrary code execution to host root via the Docker API
  • Audit a host for docker.sock exposure using mount, ls, and find commands
  • Choose between rootless, socket proxy, and DinD-on-separate-host as alternatives

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

/var/run/docker.sock is a unix socket that speaks HTTP, runs as root, and does not authenticate. There is no user model in the Docker API; there is no read-only mode; there is no per-endpoint permission. A process that can connect() to the socket has the complete Docker API, and the complete Docker API is root on the host. The escalation takes four documented API calls and no kernel bug. The audit is two commands; the fix is to remove the mount.

The escalation path

The escalation from arbitrary code execution in a container to root on the host is four API calls:

flowchart LR
    A["Step 1:\nConnect to /var/run/docker.sock\nPOST /containers/create"] --> B["Step 2:\nCreate a new container\nwith host root bind-mounted"]
    B --> C["Step 3:\nStart the container\nwith privileged flags"]
    C --> D["Step 4:\nRead/write host root\nvia the new container"]
  • Step 1 — connect to the socket. The attacker opens a connection to /var/run/docker.sock. Plain HTTP over the unix socket; no token, no header, no credential. The daemon accepts because the socket’s file mode permits it.
  • Step 2 — POST /containers/create. The attacker sends a POST with a spec that binds the host’s root filesystem (/) into the new container. The spec is the attacker’s: image, command, mounts, capabilities, namespaces. The daemon evaluates the spec on the host, outside the runner container’s confinement. The runner container’s --cap-drop=ALL, seccomp, AppArmor, and read-only root are irrelevant.
  • Step 3 — start the new container. POST /containers/{id}/start. The new container runs as host root (because the daemon runs as host root), with host root mounted.
  • Step 4 — read or write the host. Write an SSH key to /root/.ssh/authorized_keys; install a cron job in /etc/cron.d/; modify /etc/passwd to add a UID-0 user. The attacker is host root.

No kernel bug. No container escape. No CVE. The attacker asked the daemon politely and the daemon complied.

The audit

The audit for socket exposure is two commands on every runner host:

# Show every mount whose source is docker.sock
mount | grep docker.sock
# Expected empty output. Any line is a finding.

# Show the socket itself, its mode, and its group
ls -la /var/run/docker.sock
# Typical output:
# srw-rw---- 1 root docker ... /var/run/docker.sock
# Anyone in the docker group can connect.

The first command catches the case where the socket is bind-mounted into a container. The second catches the case where the runner process is in the docker group and can connect directly. Both are findings.

For a fleet-wide audit, the command runs from a central configuration manager (Ansible, Puppet, Salt) on every runner host:

# Run on every runner host via Ansible
ansible runners -m shell -a 'mount | grep docker.sock || true'
# Any non-empty output is a finding.

For container-level audit, the standard docker inspect invocation lists every mount:

docker ps -q | xargs -r -I {} \
  docker inspect --format '{.Name} {range .Mounts}{.Source} {end}' {} \
  | grep docker.sock

The three commands (mount, ls, inspect) cover every socket exposure pattern: bind-mounted into a container, accessible via group membership, present in the container’s mount list.

Why the three usual mitigations fail

flowchart TB
    subgraph BAD["Controls that look right but are not"]
        B1["Drop all capabilities"]
        B2["Run as non-root user"]
        B3["Read-only bind mount :ro"]
    end
    B1 --> N["Escalation happens in a NEW container\ndropped caps don't travel"]
    B2 --> N2["Socket is root:docker\ngroup membership bypasses non-root"]
    B3 --> N3["Connecting to a socket is not a filesystem write"]
  • Drop all capabilities. The escalation happens in a new container the daemon creates on the attacker’s spec. The runner container’s --cap-drop=ALL does not apply to the new container.
  • Run as non-root user. The socket is typically srw-rw---- root docker. Non-root + group_add: docker is the same as root for socket purposes.
  • Read-only bind mount. Connecting to a unix socket is a socket operation; the filesystem permission mode does not apply.

The three controls do not defend against the socket risk. The structural defence is to not mount the socket.

The four real alternatives

A workflow that needs Docker on the runner has four real alternatives:

flowchart TB
    subgraph ALT["Alternatives to mounting docker.sock"]
        A1["Rootless Docker daemon\n(unprivileged user-namespace daemon)"]
        A2["Filtering socket proxy\n(tecnativa/docker-socket-proxy)"]
        A3["DinD on a separate host\n(disposable, no production creds)"]
        A4["Buildah / kaniko / img\n(no daemon needed for builds)"]
    end
  • Rootless Docker daemon. The daemon runs as an unprivileged user in a user namespace. The socket lives at $XDG_RUNTIME_DIR/docker.sock and is owned by that user. A container that reaches the socket can still create new containers, but they are created by an unprivileged daemon — “host root” is no longer at the end of the chain.
  • Filtering socket proxy (tecnativa/docker-socket-proxy). An HTTP proxy sits between the workload and the socket and rejects requests by method and path. The workload uses DOCKER_HOST=tcp://socket-proxy:2375 and reaches a method-filtered API. The POST: 0 setting is what makes this safe: every enabled section becomes read-only.
  • DinD on a separate host. A dedicated DinD runner with no production credentials, no cluster access, and no persistent state. The DinD runner is built per job from a base image, executes the DinD build, and is destroyed.
  • Daemon-less builders (Buildah, kaniko, img). Tools that build OCI images without a Docker daemon. For workflows that only need to build images, daemon-less builders are the cleanest fit.

Production discipline

  1. Audit every runner host quarterly. mount | grep docker.sock returns empty; ls -la /var/run/docker.sock shows the socket owned by root:docker with no surprise consumers. Findings are remediated before the next audit cycle.
  2. No docker.sock on the runner. The runner uses rootless, daemon-less builders, or a separate DinD host. The runner’s filesystem never contains the host’s socket.
  3. Treat :ro on docker.sock as a finding. A read-only bind mount is not a control; it is a misconfiguration.
  4. Document the alternative chosen for each workflow. The declaration is in the workflow file and is reviewed on every change.

Cross-course references

  • Docker for Production Sysadmins — Part XXXV-04 (Docker socket security) covers the full socket escalation path and the filtering proxy configuration in detail.
  • Docker for Production Sysadmins — Part XXXV-05 (Privileged containers) covers the capability and device surface that amplifies a socket escape.
  • Git, CI/CD & GitOps — Part XL-03 (Ephemeral runners) covers the runner class that makes the DinD-on-host alternative tractable.
  • Linux for Production Sysadmins — Part XII (RepositorySecurity) covers the audit commands at the host level.

Quiz

Knowledge check · 4 questions

  1. Q1. Why is mounting /var/run/docker.sock into a runner container equivalent to giving the container root on the host?

  2. Q2. Mounting /var/run/docker.sock into a runner container with the `:ro` (read-only) flag is a sufficient defence because the read-only mount prevents any write operation against the daemon.

  3. Q3. Walk the four-step escalation path from arbitrary code execution in a runner container with docker.sock mounted to root on the host.

  4. Q4. Audit a self-hosted runner host for docker.sock exposure and recommend the remediation for each finding.

    An engineer runs `mount | grep docker.sock` on a self-hosted runner host and gets an empty result. Then runs `ls -la /var/run/docker.sock` and gets `-rw-rw---- 1 root docker ... /var/run/docker.sock` (the socket is there, owned root:docker). Then runs `docker ps -q | xargs docker inspect` on the host and finds one container named `ci-build` with `/var/run/docker.sock` bind-mounted. The runner hosts are persistent and run fork-PR builds.

Passing score: 75%. Answers are checked in this browser.