Skip to content

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 explicit user: maps to that uid) plus _t18_rootfs_write, _t18_tmpfs_inherits and _t18_volume_ownership for 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 it uid=/gid= options (/run:uid=1000,gid=1000). A tmpfs at a path absent from the image defaults to mode 1777 and 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-0003no-new-privileges:true (limits privilege gain via execve regardless of starting UID)