Skip to content

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 — :ro does 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: _cl0001 and _cl0001_ro_socket in scripts/validate_rule_premises.py — a :ro-mounted socket answered HTTP/1.1 200 OK on 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:

  1. A mount naming a control socketdocker.sock, containerd.sock, crio.sock, or podman.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 called docker.sock) can produce false positives; suppress with a reason: if so.
  2. A mount of a directory that contains one/run, /var/run, /run/containerd, /run/systemd, or any directory above them such as /var or the whole root /. This was the rule's blind spot: mounting /run hands 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-0024cap_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