Skip to content

CL-0013: Sensitive host path exposed

Severity: HIGH

Derivation (see severity model):

  • Baseline: A — the attacker already has code execution in this container, as the workload uid
  • Precondition: Direct — reading a mounted host path needs no technique and no second defect
  • Impact: Host — host configuration, kernel interfaces, credentials and user data
  • Qualifier/modifier: read-only — what these mounts grant is disclosure and a weakened boundary, not host write. A writable mount of a root-equivalent path is CL-0025, one tier up
  • Derived: Direct × Host + read-only = HIGH
  • Shipped: HIGH
  • Evidence: _cl0013 in scripts/validate_rule_premises.py — a host bind mount exposes the host path. Member claims are graded individually rather than assumed: _cl0013_dev_bind_is_gated proves a /dev bind is refused by the device cgroup, and _cl0016_raw_disk proves the same read succeeds through devices:. One member claim is a captured observation rather than a check: /sys's uevent_helper is not writable at default capabilities (Docker 29.1.3), which is why /sys is disclosure and a weakened boundary rather than escape

References: - OWASP Docker Security Rule #8 - CIS Docker Benchmark 5.6 — Ensure sensitive host system directories are not mounted on containers

What it detects

Services that bind-mount a host path whose exposure is disclosure or a weakened boundary rather than host root: - /sys — kernel device and driver interfaces, in either mode - a path strictly below /run or /var/run — host runtime state: the system bus (/var/run/dbus), the libvirt control socket, udev's device database, utmp. The directories themselves and anything above them are CL-0001 at CRITICAL, because those hold the daemon control socket; what sits below does not - /dev — the entire host device tree, in either mode. The device nodes become visible but stay unusable — a bind is not devices:, see below. The inert character devices /dev/null, /dev/zero, /dev/full, /dev/random and /dev/urandom are excluded: they disclose no host state and grant no access - /home itself, or a single user's home directory (/home/alice) — the whole tree, with credentials, source code and shell history, in either mode - a credential directory inside a home directory — .ssh, .docker, .aws, .kube, .gnupg, and anything below them — in either mode - read-only mounts of the root-equivalent paths /etc, /proc, /boot, /root, /var/lib/docker, /var/lib/containerd and /var/lib — the same paths writable are CL-0025 at CRITICAL - a writable /etc/localtime or /etc/timezone — see the timezone exception below

Subpaths (e.g., /etc/passwd, /root/.ssh) are also flagged. Both short syntax (/etc:/host-etc) and long syntax (source: /etc, target: /host-etc) are checked. The long syntax is recognized whether type: bind is set explicitly or omitted — Compose treats absolute-path sources as bind mounts either way. Named volumes are not flagged.

The home tree is matched by depth, not by descent. /home and /home/alice are the user's data; /home/alice/projects/app/data is the application's own directory and is not flagged. This distinction matters because a relative bind source resolves against the compose file's directory — ./data becomes an absolute path under wherever the file sits, which for most real projects is under /home. Matching the whole subtree would flag the commonest bind idiom in Compose. Credential directories keep the subtree match, because their grant does not weaken further down.

Read-only and read-write mounts are graded differently. :ro on a root-equivalent path lands here at HIGH — readable secrets, no host modification — while the same path writable is CL-0025 at CRITICAL. For /sys, /dev and the home-tree paths the mode does not change the tier. Suppress with reason: if your use case genuinely only reads non-sensitive subdirectories, or scope the mount narrower.

Handled by another rule. A whole-root mount (/), the control-socket directories /run, /var/run, /run/containerd and /run/systemd, and any directory above them are CL-0001 at CRITICAL in either mode, because they expose the daemon control socket whether or not the mount is writable. /var/lib/kubelet is no longer flagged at all: its danger is entirely conditional on Kubernetes being present, so it cannot be premise-checked on the grounded target and it fails the same bar that removed CL-0023 (ADR-020).

The timezone exception

- /etc/localtime:/etc/localtime:ro (and /etc/timezone) is exempt. Read-only it exposes the host's UTC offset and nothing else, and it appears on a large share of otherwise-hardened files — flagging it HIGH failed the default gate on services that had done everything else right (issue #509).

Written without :ro it still fires here, at HIGH: the container can then overwrite what the host reads as local time. It is deliberately not CL-0025's CRITICAL, because writing that one file is not host root — grading it there would repeat #509's mistake one tier up.

Why it matters

Mounting these paths hands the container host state it has no business reading: - /sys provides direct interfaces to hardware, cgroups, and kernel modules - /dev conveys the host's entire device inventory — the nodes become visible, though the device cgroup still denies use; see the note below - a home directory typically contains SSH keys, cloud credentials, and shell history — which is also why ~/.ssh, ~/.aws, ~/.docker and ~/.kube are flagged on their own, without the rest of the tree - a read-only /etc still leaks shadow, sudoers and SSH host keys - a read-only /proc exposes kernel parameters and process environments, including secrets - a read-only /root leaks authorized_keys and shell history - a read-only /boot discloses the kernel and initramfs in use - a read-only /var/lib/docker discloses other containers' filesystems and image layers - a writable /etc/localtime or /etc/timezone lets the container change what the host reads as local time

Writable, the five root-equivalent paths are host root rather than disclosure, which is why CL-0025 owns them at CRITICAL.

A /dev bind mount is not devices:

Bind-mounting /dev conveys the device nodes, but not permission to use them. Docker's per-container device cgroup still denies access to any device not granted with devices: or --device, so the nodes are visible and unusable. Verified on Docker 29.1.3, same host, same device:

via a /dev bind mount:  dd: can't open '/hostdev/nvme0n1': Operation not permitted
via --device:           512 bytes copied

An earlier revision of this page called a /dev bind "equivalent to many of CL-0016's individual flags". It is not: CL-0016 flags devices: entries, which pass the cgroup gate, and that is why CL-0016 is the rule that owns raw device access. The /dev bind is still worth flagging — it discloses the host's device inventory and it is a strong signal of intent — but it does not by itself grant raw block-device I/O.

Fix

Remove the bind mount. If the container needs specific host files, copy them into the image at build time or use a named volume containing only the required data:

# Before
services:
  app:
    image: myapp:1.0
    volumes:
      - /etc:/host-etc

# After — use a named volume or COPY in Dockerfile
services:
  app:
    image: myapp:1.0
    volumes:
      - app-config:/app/config

/dev/shm and /dev/hugepages

For these two, "remove the mount" on its own is not followable — the workload wants the facility, not the host's copy of it. Compose can give it the facility without the host exposure, and the finding names the alternative directly.

/dev/shm. A container already has its own /dev/shm, 64 MiB by default. If the bind exists only because that is too small — the usual reason, and why Chrome and Selenium images ask for it — resize it:

# Before
services:
  browser:
    image: selenium/standalone-chrome:130.0
    volumes:
      - /dev/shm:/dev/shm

# After — a bigger private segment, host's untouched
services:
  browser:
    image: selenium/standalone-chrome:130.0
    shm_size: 2gb

If two services genuinely need to share one segment, scope the sharing to them rather than to the host:

services:
  owner:
    image: myapp:1.0
    ipc: shareable
  worker:
    image: myapp:1.0
    ipc: service:owner

Both services then see one shared /dev/shm; the host and every other container are excluded. Sharing the host's namespace instead — ipc: host — is CL-0010, and is the thing this avoids.

/dev/hugepages. A local volume backed by hugetlbfs mounts a fresh hugetlbfs instance, so the workload keeps huge pages without seeing the host's:

services:
  spdk:
    image: myapp:1.0
    volumes:
      - hugepages:/dev/hugepages
    deploy:
      resources:
        limits:
          memory: 4G

volumes:
  hugepages:
    driver: local
    driver_opts:
      type: hugetlbfs
      device: hugetlbfs

A file created inside that volume is invisible on the host's /dev/hugepages and through a bind of it (verified on Docker 29.4.3), so the container cannot read or corrupt pages another workload mapped. What it does not change is the size of the pool: huge pages are a host-wide resource, so bound the service with deploy.resources.limits as well.

If the workload really does need the host's own huge-page files — some DPDK and SPDK deployments coordinate through them — that is a case for suppressing the finding with a reason:, not for pretending the mount is safe.

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
T1552.001 Unsecured Credentials: Credentials In Files Credential Access

See also

  • CL-0001/var/run/docker.sock (the canonical privileged socket under /var/run)
  • CL-0016 — individual dangerous host devices (per-device, where this rule covers whole /dev)