Git, CI/CD & GitOpsXLI · Runner SecurityDockerSocket
The Docker socket risk — docker.sock mounted into a runner = root on the host; the escalation path
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
/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 aPOSTwith 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/passwdto 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=ALLdoes not apply to the new container. - Run as non-root user. The socket is typically
srw-rw---- root docker. Non-root +group_add: dockeris 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.sockand 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:2375and reaches a method-filtered API. ThePOST: 0setting 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.
Why the daemon has no authorisation layer
The Docker API was designed as a local administrative
interface, in the same category as /dev/kvm or the
systemd private socket: access control is the file mode on
the socket, and everything past that point is trusted. That
decision is baked deep enough that adding per-endpoint
permissions to the daemon is not on the table.
You can watch it work with nothing but curl, which is also
the cleanest way to prove a finding without running anything
destructive:
curl -s --unix-socket /var/run/docker.sock http://localhost/version | head -c 200A 200 response with a JSON version document means the
caller has the complete API. There is no second step, no
login, no token exchange.
Production discipline
- Audit every runner host quarterly.
mount | grep docker.sockreturns empty;ls -la /var/run/docker.sockshows the socket owned by root:docker with no surprise consumers. Findings are remediated before the next audit cycle. - 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.
- Treat
:roon docker.sock as a finding. A read-only bind mount is not a control; it is a misconfiguration. - 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
Q1. Why is mounting /var/run/docker.sock into a runner container equivalent to giving the container root on the host?
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.
Q3. Walk the four-step escalation path from arbitrary code execution in a runner container with docker.sock mounted to root on the host.
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.