CL-0018: user: root — and non-root “Permission denied” fixes¶
Severity: MEDIUM
Derivation (see severity model):
- Baseline: A — the attacker already has code execution in this container, as the workload uid
- Precondition: Second flaw — the named primitive is uid 0 inside the container together with the default capability set. Turning that into impact needs something outside this key: a kernel defect, or a host path this file mounts
- Impact: Single container
- Qualifier/modifier: none
- Derived: Second flaw × Single container = MEDIUM
- Shipped: MEDIUM
- Scoping assumptions: Scored with no host bind mount present. With one, container root writes host-root-owned files and the impact would be Host — that combination is scored by CL-0013, which owns the mount
- Evidence:
_cl0018(an explicituser:maps to that uid) plus_t18_rootfs_write,_t18_tmpfs_inheritsand_t18_volume_ownershipfor the non-root failure modes below
References: - OWASP Docker Security Rule #2 - Docker security: rootless mode and userns-remap
What it detects¶
Any service whose user: value has a root user portion — root or 0 — regardless of the group. This covers user: root, user: "0", user: "root:root", and user: "0:0", but also user: "root:1000", user: "0:staff", and any other root:<group> / 0:<gid> form (only the part before the : is checked). Services without a user: directive are not flagged — the absence of user: does not imply root, as the image's USER instruction determines the UID.
Why it matters¶
Explicitly setting user: root starts the container's init process as UID 0 with Docker's full default capability set, where an image that declares a non-root USER would have started it unprivileged.
On gosu/su-exec images, user: root is the precondition for the privilege drop, not an undoing of it. These images (official postgres, redis, mysql, and friends) are designed to start as root and step down in the entrypoint, so the finding is about the images that would otherwise have run unprivileged — not about defeating the entrypoint. Verified on Docker 29.1.3: postgres:17-alpine runs PID 1 as postgres both with and without user: root. An earlier revision of this page had the mechanism backwards.
A container whose workload actually runs as UID 0:
- Has full write access to all bind-mounted host paths (subject to the userns caveat below)
- Can exploit kernel vulnerabilities more easily (many require CAP_SYS_ADMIN or UID 0)
- Amplifies the impact of every other misconfiguration (e.g., privileged, cap_add, host mounts)
This rule catches the explicit override case. It does not flag the absence of user: — that would be unactionable without knowing the image's intended UID. Tools like Trivy and hadolint take the opposite tack and flag absence; compose-lint deliberately stays narrow to keep findings actionable.
Userns / rootless interaction¶
If the daemon is configured with userns-remap or you're running rootless Docker, container UID 0 maps to a high-numbered host UID (e.g. 100000), and bind-mount writes obey host permissions for that UID — not host root. Explicit user: root is still a finding in that case because:
- Most installations do not configure userns-remap (it's opt-in and breaks many workloads).
- Even with remap, UID 0 inside the container still owns most of the in-container filesystem and amplifies kernel-bug exploitability.
- A future operator who toggles remap off (e.g. on a different host) silently inherits a more dangerous configuration.
The rule fires regardless of daemon config; suppress only when you have verified the threat model on a specific host.
Fix¶
Remove the user: directive to respect the image's built-in USER instruction, or set an explicit non-root user:
# Before
services:
app:
image: myapp:1.0
user: root
# After — let the image decide
services:
app:
image: myapp:1.0
# Or — set a specific non-root user
services:
app:
image: myapp:1.0
user: "1000:1000"
Reading the failure (after switching to non-root)¶
The flip side of this rule: once a service runs as a non-root user:, writes to root-owned paths fail with Permission denied (EACCES). The remedy depends on the mount type of the failing path. Busybox wordings are re-proven against a live container on every CI run (scripts/validate_rule_premises.py); coreutils variants were captured live but are not CI-asserted.
| Symptom in logs (verbatim) | Failing path is… | Remedy |
|---|---|---|
touch: /etc/app.lock: Permission denied (busybox) · touch: cannot touch '/etc/app.lock': Permission denied (coreutils) |
a root-owned path in the image's filesystem | a tmpfs: entry with uid=/gid= options — see the tmpfs gotcha below |
| same, under a bind mount | a host directory owned by another uid | chown the host directory to the container uid (the PUID/PGID convention exists for exactly this) |
| same, under a named volume at a path that does not exist in the image | a fresh volume — Docker creates it root-owned | pre-create and pre-own the directory in the image build, or chown it once from a root one-shot container |
| works on first deploy, fails after moving the volume | volume initialized from a different image's ownership | same remedies; volume ownership is set at first mount, not per-run |
permission denied binding a port below 1024 as non-root |
not a filesystem problem | see CL-0006's NET_BIND_SERVICE row — under default per-container network namespaces low ports need no privilege (Docker 20.10+); under network_mode: host grant the capability |
Two facts behind those rows, both CI-proven:
- The tmpfs gotcha: a tmpfs mounted over a directory that exists in the image inherits that directory's ownership and mode — so a bare
tmpfs:entry over a root-owned dir is still unwritable for a non-root user. Give ituid=/gid=options (/run:uid=1000,gid=1000). A tmpfs at a path absent from the image defaults to mode1777and just works. - Named-volume initial ownership: a fresh volume mounted at a path absent from the image is root-owned; mounted over an existing image directory, Docker copies that directory's contents and ownership into the volume on first use. Where the image prepares the directory for its runtime uid, the copy-up does the right thing — otherwise you own the fixup.
As with every hardening step here: after switching to non-root, verify function, not just startup — applications catch EACCES and quietly continue with uploads, caches, or state persistence disabled.
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 |
| T1078 Valid Accounts | Privilege Escalation |
See also¶
- CL-0003 —
no-new-privileges:true(limits privilege gain viaexecveregardless of starting UID)