CL-0025: Root-equivalent host path mounted writable

August 25, 2026 · View on GitHub

Severity: CRITICAL

Derivation (see severity model):

  • Baseline: A — the attacker already has code execution in this container, as the workload uid
  • Precondition: Direct — writing a file is a supported operation. No exploit, no published technique, no second defect
  • Impact: Host — each member reaches host root, or code executing as it
  • Qualifier/modifier: none — this is write access, so the read-only qualifier that puts the same paths in CL-0013 does not apply
  • Derived: Direct × Host = CRITICAL
  • Shipped: CRITICAL
  • Evidence: _cl0025_core_pattern, _cl0025_exec_tree and _cl0025_module_tree in scripts/validate_rule_premises.py. The first: a read-write /proc bind arrives writable at default capabilities where the container's own /proc/sys is read-only, so core_pattern can be repointed (the check writes back the value it read, leaving the host's setting unchanged). The second: a container given only -v /usr plants a root-owned executable in /usr/local/bin that a second container sees through its own bind, while the same write through a read-only bind is refused (the check removes the file). The third: the same plant-observe-remove through -v /lib/modules into the running kernel's module directory, where the host loads by name; the kernel is never asked to load it. The other members are classic direct-root primitives (cron.d, ld.so.preload, authorized_keys, the initramfs)

What it detects

A writable bind mount of a host path where write access is host root:

PathWhat writing there gets an attacker
/etccron.d, ld.so.preload, sudoers, shadow — host root on the next scheduled job or login
/rootauthorized_keys — host root over SSH
/bootthe kernel and initramfs — a rootkit that survives reboot
/var/lib/dockerevery other container's filesystem and image layers; tamper with one and it escapes on next start
/var/lib/containerdthe containerd snapshot trees holding every container's filesystem — on Docker 29 with the containerd snapshotter, that is where the layers live
/var/lib/var/lib/docker and /var/lib/containerd inside it, and with them every other container's filesystem. Matched exactly — see below
/proccore_pattern — the host runs a program of the attacker's choosing, as root, on the next crash
/usr/bin, /usr/sbin, /usr/local/bin, /usr/local/sbin, /bin, /sbinroot's PATH — a binary planted or replaced here runs as root on the next cron job, login or unit start. Nothing need be overwritten: /usr/local/bin precedes /usr/bin on root's PATH, so a planted name shadows the real one
/usrthe executable tree above, by containment. Matched exactly — see below
/lib/modules, /usr/lib/modulesthe kernel modules the host loads by name, as root, on demand — udev, a protocol coming up, the next boot. A replaced module is kernel-mode code on the host; no CAP_SYS_MODULE needed, the host does the loading
/usr/lib, /lib, /lib64systemd/system vendor units and the libraries every root process links against. Matched exactly — see below

Every path above is matched by descent — /etc/passwd, /var/lib/docker/volumes, /usr/bin/docker and /lib/modules/6.1.0/kernel count — except /var/lib, /usr, /usr/lib, /lib and /lib64, which are matched only as an exact mount. Each is root-equivalent because of what it contains, not because of what lies below it: -v /var/lib reaches the container store, while -v /var/lib/mysql reaches a database's own data directory and nothing else; -v /usr reaches /usr/local/bin, while -v /usr/src or /usr/share/zoneinfo reaches kernel headers or timezone data that root never executes. Descent would have priced every stateful service's normal, correct configuration — /var/lib/mysql, /var/lib/postgresql/data, /var/lib/grafana — as host root, and measured over the corpus 6 of the 27 writable /usr-family binds (22%) were application data of that kind.

The executable tree is listed under both spellings. On a merged-/usr host /bin is a symlink to usr/bin and Docker resolves it at mount time, but this rule matches what the document wrote, so /bin:/bin and /usr/bin:/usr/bin each need their own entry. Which spelling a distro uses varies, and both are graded the same.

The library tree follows the same split (ADR-033). /lib/modules is matched by descent because everything below it is a file the host may load; /usr/lib is matched exactly because /usr/lib/python3, /usr/lib/node_modules and /usr/lib/jvm sit below it too. The writable /lib/modules bind is the standard WireGuard / strongSwan idiom (7 corpus services, all of that shape) and is a true positive: the workload only reads module files, so :ro is the whole fix and the finding leads with it. /usr/lib/systemd and the multiarch library directories are real grants with no corpus incidence and are not matched by descent yet; the exact parent prices the whole-tree bind.

A whole-root mount (/) is not listed here: it contains the daemon control socket, so it is host root in either mode — not only when writable — and CL-0001 owns it, read-only included.

Subpaths count: /etc/cron.d is /etc. Both Compose syntaxes are recognised, and the long form is treated as a bind whether type: bind is explicit or inferred from an absolute source:.

Two exceptions under /etc: /etc/localtime and /etc/timezone. They sit under a root-equivalent path but writing one changes what the host reads as local time — it is not host root, so neither is graded here in either mode. A writable timezone bind is still a finding, at CL-0013's HIGH; a read-only one is exempt entirely, because that pattern is near-universal and flagging it failed the default gate on otherwise-hardened files (issue #509).

A path handed over as a secrets: or configs: file: is never this rule: that channel is a read-only bind whatever mode: says (measured), so it is CL-0013's disclosure, or CL-0001's if it is a socket.

Read-only mounts of these paths are not this rule. They fall through to CL-0013 at HIGH, where the finding is disclosure rather than takeover. That is the whole distinction between the two rules. The exception is the executable and library trees: every file in /usr/bin or /lib/modules is world-readable by design, so a read-only bind of one discloses nothing and is exempt from CL-0013 as well — the grant is write-only, the same shape as the timezone files one tier down. /lib/modules:/lib/modules:ro is clean.

Why it matters

None of these needs an exploit. The container writes a file; the host executes it, trusts it, or boots it. /proc is the one that surprises people, so it is worth being concrete: Docker mounts a container's own /proc/sys read-only, but a bind mount of the host's /proc arrives writable, and at default capabilities — no cap_add, no privileged — the container can rewrite /proc/sys/kernel/core_pattern. That file names the program the kernel runs when any process on the host dumps core, and it runs as root. Verified on Docker 29.1.3.

/var/lib/docker is the other under-rated one: it is not host root directly, but it is every other container's filesystem, so a write there escapes into a neighbour the next time it starts.

The executable tree is the one people bind without thinking, usually as a single file — /usr/bin/docker:/usr/bin/docker so a container can drive the host's CLI. Writable, that is the host's docker binary, replaceable by the container. Measured on two hosts (Docker 29.1.3 with AppArmor; 29.7.2 without), unprivileged and at default capabilities: a writable bind of each member accepted a write, the read-only bind of the same path refused it, and a root-owned 755 file planted through -v /usr into /usr/local/bin was on the host afterwards, ahead of /usr/bin on root's PATH.

Fix

Remove the mount. If the container only needs to read, make it read-only — that is still a finding, but a materially smaller one:

# Before — host root
volumes:
  - /etc:/host-etc

# Better — disclosure only (CL-0013)
volumes:
  - /etc:/host-etc:ro

# Best — mount only what is needed
volumes:
  - /etc/myapp/config.yml:/app/config.yml:ro

Where a container genuinely needs to write host state, the usual answer is that the work belongs on the host: a systemd unit, a cron job, or an agent running outside the container, invoked through an interface narrower than a filesystem.

When to suppress

Rarely, and never globally. Backup tooling that reads /etc should be read-only and therefore not this rule at all. If a writable mount is genuinely required, suppress per service with a reason: naming what writes there and why a host process cannot do it instead.

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.

TechniqueTactic
T1611 Escape to HostPrivilege Escalation

See also

  • CL-0013 — the same paths mounted read-only, and /sys, /dev, /home in either mode
  • CL-0001 — host control sockets, which /run and /var/run expose
  • CL-0007read_only: true for the container's own filesystem