CL-0001: Host control socket exposed — root-equivalent access¶
Severity: CRITICAL
Derivation (see severity model):
- Baseline: A — the attacker already has code execution in this container, as the workload uid
- Precondition: Direct — talking to the daemon over the socket is a supported API call; no technique and no second defect
- Impact: Host — the daemon API creates privileged containers, so it is host control
- Qualifier/modifier: none —
:rodoes not earn the read-only qualifier, because the flag applies to the socket file, not to the read-write API behind it - Derived: Direct × Host = CRITICAL
- Shipped: CRITICAL
- Evidence:
_cl0001and_cl0001_ro_socketinscripts/validate_rule_premises.py— a:ro-mounted socket answeredHTTP/1.1 200 OKon the daemon API, so the mount flag narrows nothing
References: - OWASP Docker Security Rule #1 - CIS Docker Benchmark 5.32 — Ensure that the Docker socket is not mounted inside any containers
What it detects¶
Two shapes, both mode-independent:
- A mount naming a control socket —
docker.sock,containerd.sock,crio.sock, orpodman.sock, including read-only mounts (:ro) and rootless variants ($XDG_RUNTIME_DIR/docker.sock). Detection is substring-based, so atypical paths (e.g. a named volume literally calleddocker.sock) can produce false positives; suppress with areason:if so. - A mount of a directory that contains one —
/run,/var/run,/run/containerd,/run/systemd, or any directory above them such as/varor the whole root/. This was the rule's blind spot: mounting/runhands over both daemon sockets without naming either, and it used to be reported as a generic sensitive-path finding one tier down (or, before #513, not at all). A whole-root mount is included in either mode — a read-only/still exposes the socket, and used to be under-graded to CL-0013's HIGH because CL-0025 (writable only) declined it.
The direction matters. A sibling under /run that holds no socket — /run/myapp, /run/user/1000, /run/dbus — is not flagged, because mounting it exposes no control channel. /run/systemd/private is flagged by name, since it is a socket that does not end in .sock.
containerd.sock is core Docker coverage rather than multi-engine support: Docker Engine is containerd since 18.09, so a stock Docker host always runs it — and it is a lower-level API with no authorization-plugin layer above it. podman.sock and crio.sock belong to other ecosystems but stay flagged, because this rule is about what a compose file exposes into a container, not about which engine started it (ADR-020).
/run/systemd deserves its own mention: a container can connect to /run/systemd/private and authenticate to it at default capabilities, then drive StartTransientUnit — command execution on the host, without going through Docker at all.
A socket can also reach a container through a named volume that is a bind mount under another name — driver_opts: {type: none, device: /var/run, o: bind} under the local driver. That is detected by the shape the kernel acts on (type: none plus an absolute device), not by the o: string being exactly bind, so the whole bind family counts: o: rbind is a recursive bind of the same host path, and matching the token literally let it through. A driver_opts naming something that is not a host path — type: nfs, type: tmpfs — is not claimed as a bind.
Why it matters¶
Mounting the Docker socket gives a container full root-level access to the host's Docker daemon. A compromised container can:
- Create new privileged containers
- Access data from all other containers
- Escape to the host entirely
Read-only mounts (:ro) do not mitigate this — :ro only affects the inode permissions on the socket file. The Docker API is read-write over any open connection regardless of the mount flag, and docker run --privileged is a single API call.
Rootless Docker and Podman sockets are slightly less catastrophic — escape lands you as the unprivileged daemon user, not host root — but they still grant full container-management privilege over every container that user controls. Treat them as the same severity unless you've verified the threat model.
Fix¶
Use a Docker socket proxy that exposes only the API endpoints your service needs, and harden the proxy itself so it isn't the new weakest link:
services:
socket-proxy:
image: tecnativa/docker-socket-proxy:0.3.0 # pin a version (CL-0004)
environment:
CONTAINERS: 1
SERVICES: 0
TASKS: 0
POST: 0 # deny mutating endpoints unless required
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
read_only: true
cap_drop:
- ALL
cap_add:
- SETUID # haproxy entrypoint switches users
- SETGID
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
- proxy
traefik:
environment:
DOCKER_HOST: tcp://socket-proxy:2375
networks:
- proxy
This is the fix recommended in canonical Traefik / portainer-with-proxy guides. The proxy itself still mounts the socket — that's why it must be at least as hardened as anything else holding that mount.
When to suppress¶
- The service is the daemon-management tool you trust (Portainer, Watchtower) and you've accepted the blast radius. Suppress with a
reason:naming the tool and the user/network boundary that contains it.
ATT&CK coverage¶
Remediating this finding contributes to mitigating the following MITRE ATT&CK techniques (pinned to ATT&CK v18). compose-lint is a static analyser, so this is mitigation coverage — it detects nothing at runtime.
| Technique | Tactic |
|---|---|
| T1610 Deploy Container | Execution |
| T1609 Container Administration Command | Execution |
| T1611 Escape to Host | Privilege Escalation |
| T1612 Build Image on Host | Stealth |
| T1552.007 Unsecured Credentials: Container API | Credential Access |
| T1613 Container and Resource Discovery | Discovery |
See also¶
- Real-world examples — four deployed stacks that trip this rule and the four different correct answers: delete the service, re-architect so the socket isn't needed, constrain it behind a GET-only proxy, or suppress it with the residual risk written down
- CL-0024 —
cap_add: [ALL], functionally similar in blast radius - CL-0025 — writable root-equivalent host mounts, the other no-technique route to host root
- CL-0013 — sensitive host paths that are not control sockets