Skip to content

CL-0003: no-new-privileges — blocking setuid/sudo privilege escalation

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 a setuid-root or file-capability binary in the image. Common, but it is a property of the image rather than of this file
  • Impact: Single container — the capability bounding set still caps what can be regained
  • Qualifier/modifier: none
  • Derived: Second flaw × Single container = MEDIUM
  • Shipped: MEDIUM
  • Evidence: _cl0003 (the flag is off by default) and _t3_setuid_inert (with the flag set, the setuid exec succeeds but euid is unchanged — the silent failure this page describes)

References: - OWASP Docker Security Rule #4 - CIS Docker Benchmark 5.26 — Ensure that the container is restricted from acquiring additional privileges

What it detects

Any service missing no-new-privileges:true in its security_opt: list.

Why it matters

no_new_privs (kernel: prctl(PR_SET_NO_NEW_PRIVS)) blocks any privilege gain at execve() — including file capabilities (setcap-set xattrs on a binary) and the seccomp/LSM-bypass paths a setuid root binary can otherwise unlock. Without it, an attacker with shell access in a container started as a non-root user can pivot through any setuid binary or capability-marked binary the image ships, regaining privileges the runtime had otherwise constrained. CVE-2019-5736-style escape chains assume this gate is open.

Docker bounds the capability set regardless, so a setuid root binary alone won't gain capabilities the container doesn't already have. What it can regain is uid 0 inside the container, which is enough to read and rewrite anything in the container's own filesystem that the workload uid could not.

An earlier revision of this page claimed that seccomp filters and LSM transitions are bypassable through execve of a setuid binary unless no_new_privs is set. That is wrong and has been removed: both survive the transition — a container's Seccomp: 2 and its docker-default (enforce) AppArmor profile are unchanged after execve of a setuid binary. no_new_privs blocks the privilege gain; it is not what keeps seccomp or the LSM attached.

Fix

security_opt:
  - no-new-privileges:true

Compatibility

no-new-privileges blocks privilege gain at execve() — it does not block privilege drop. The distinction decides what breaks:

  • Unaffected: entrypoints that drop from root to an unprivileged user via gosu/su-exec (official postgres, redis, mysql, valkey, …). A root process calling setuid() downward needs no execve-time gain, so no_new_privs never engages. Live-verified on both valkey and postgres (su-exec entrypoints): each runs healthy under no-new-privileges with its server processes as the unprivileged user and clean logs, and the drop path is re-proven against a live container on every CI run (scripts/validate_rule_premises.py). An earlier revision of this page claimed these images crash-loop — that was wrong.
  • Affected: anything that needs to gain privilege after starting unprivilegedsudo/su invoked by a non-root user, setuid-root helper binaries, and binaries carrying file capabilities (setcap xattrs; live-verified — the exec succeeds and the capability is silently not granted). The monitoring-agent case is the subtle one: netdata's apps.plugin is setuid root in its container image (live-verified on a running deployment), so under nnp the agent stays healthy while that collector quietly runs unprivileged.
  • Services that already run as a non-root user and never invoke a privilege-gaining helper are safe by construction.

Reading the failure

The trap is that nnp rarely announces itself: the execve of a setuid binary succeeds — the kernel just ignores the setuid bit and file capabilities, so the process runs with unchanged privileges and fails later with ordinary permission errors. CI re-proves this silence live (the setuid exec returns exit 0 with euid unchanged); the sudo wording below was captured from a live container but is not CI-asserted.

Symptom in logs (verbatim) What happened Action
sudo: The "no new privileges" flag is set, which prevents sudo from running as root. (newer sudo versions add: If sudo is running in a container, you may need to adjust the container configuration to disable the flag.) sudo detects nnp explicitly — the friendly case in-container sudo is a design smell: run the step as root before dropping privileges, or bake the permission in; remove nnp only with a documented suppression reason
ordinary Permission denied / Operation not permitted from a helper that "should" be root, with no nnp mention a setuid binary or setcap'd file executed fine but ran unprivileged — the silent case confirm with grep NoNewPrivs /proc/<pid>/status (1 = flag set), then redesign as above
a monitoring agent runs "healthy" while privileged collectors report nothing its setuid or setcap'd helpers silently ran unprivileged (both variants verified live; the setuid one is CI-proven) same confirmation; grant the specific capability to the container (see CL-0006) instead of relying on in-image helpers, or accept the reduced collector set

Do not map a crash-looping setuid: Operation not permitted entrypoint failure to this setting — that is the cap_drop: [ALL] symptom (CL-0006's SETUID+SETGID row); nnp leaves root's downward setuid() alone.

How to test

Add the setting, docker compose up, and — because the failure mode is silent — verify function, not just startup: read the logs and exercise the service's privileged behaviors. A service that breaks is gaining privilege somewhere; find the helper, redesign it, and only suppress with a documented reason if the gain is genuinely required.

Per-service handling

Where a specific service genuinely needs to gain privilege, exempt that service — never the rule. Per-service overrides shipped in 0.4.0 (ADR-010):

# .compose-lint.yml
rules:
  CL-0003:
    exclude_services:
      backup-agent: "runs restic via a setuid helper; reviewed 2026-01"

The excluded service still produces a suppressed finding carrying that reason, so the exemption stays visible in JSON and SARIF output rather than disappearing. Disabling CL-0003 globally turns off a security rule for every service in the file to accommodate one of them, and is not the right answer.

Further reading

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
T1548.001 Abuse Elevation Control Mechanism: Setuid and Setgid Privilege Escalation (Enterprise/Linux, not on the Containers matrix)

See also

  • CL-0006 — capability dropping (defense-in-depth sibling)
  • CL-0009 — seccomp/AppArmor profiles (the LSM gate no_new_privs keeps closed across execve)