CL-0025: Root-equivalent host path mounted writable¶
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_patterninscripts/validate_rule_premises.py— 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 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 |
Every path above is matched by descent — /etc/passwd and /var/lib/docker/volumes
count — except /var/lib, which is matched only as an exact mount. It 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. Descent would have priced every
stateful service's normal, correct configuration — /var/lib/mysql,
/var/lib/postgresql/data, /var/lib/grafana — as host root.
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).
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.
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.
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 |