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-onlyqualifier that puts the same paths in CL-0013 does not apply - Derived: Direct × Host = CRITICAL
- Shipped: CRITICAL
- Evidence:
_cl0025_core_pattern,_cl0025_exec_treeand_cl0025_module_treeinscripts/validate_rule_premises.py. The first: a read-write/procbind arrives writable at default capabilities where the container's own/proc/sysis read-only, socore_patterncan be repointed (the check writes back the value it read, leaving the host's setting unchanged). The second: a container given only-v /usrplants a root-owned executable in/usr/local/binthat 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/modulesinto 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:
| Path | What writing there gets an attacker |
|---|---|
/etc | cron.d, ld.so.preload, sudoers, shadow — host root on the next scheduled job or login |
/root | authorized_keys — host root over SSH |
/boot | the kernel and initramfs — a rootkit that survives reboot |
/var/lib/docker | every other container's filesystem and image layers; tamper with one and it escapes on next start |
/var/lib/containerd | the 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 |
/proc | core_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, /sbin | root'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 |
/usr | the executable tree above, by containment. Matched exactly — see below |
/lib/modules, /usr/lib/modules | the 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, /lib64 | systemd/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.
| Technique | Tactic |
|---|---|
| T1611 Escape to Host | Privilege Escalation |