Skip to content

CL-0006: cap_drop ALL — mapping “Operation not permitted” to capabilities

Severity: MEDIUM

Derivation (see severity model):

  • Baseline: A — the attacker already has code execution in this container, as the workload uid
  • Precondition: Technique — ARP cache poisoning against the NET_RAW primitive Docker grants by default. A published technique that needs no software defect in any victim; ARP is unauthenticated by design
  • Impact: Cross-container — L2 neighbours on the same Docker network only
  • Qualifier/modifier: none — the payoff is conditional on a reachable neighbour, but that condition is priced by the override rather than by a qualifier, so it is not counted twice
  • Derived: Technique × Cross-container = HIGH
  • Shipped: MEDIUM — override detection-precision — the derived impact needs a co-resident neighbour emitting interceptable traffic, and this rule asks only whether cap_drop: [ALL] is present, so it fires on single-service stacks and on stacks that encrypt inter-service traffic alike (90.8% of real compose files, the large majority with no exploitable neighbour). Services and networks are both declared in the file, so the matcher can be sharpened — revisit the override when it is. ADR-020 Appendix A carries the full dossier
  • Scoping assumptions: Docker Engine defaults — --icc true, so neighbours are reachable; the capability set fixed at the default 14 with NET_RAW included (unconfigurable; dockerd has no capability flag)
  • Evidence: _cl0006 plus the eleven symptom-table mapping checks. The cross-container reach is observed end to end: a default-caps container overwrote a neighbour's ARP entry for the gateway and held it across 5/5 polls, and the same attempt across two separate Docker networks got no response (ADR-020 Appendix A)

References: - OWASP Docker Security Rule #3 - CIS Docker Benchmark 5.4 — Ensure that Linux kernel capabilities are restricted within containers

What it detects

Any service that does not include cap_drop: [ALL] in its configuration.

Dropping only specific capabilities (e.g., cap_drop: [NET_RAW]) is not sufficient — the rule requires ALL to ensure a least-privilege baseline.

Why it matters

Docker's default capability set (moby/daemon/pkg/oci/caps/defaults.go) grants 14 capabilities to every container: AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE, NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT.

This set is fixed and unconfigurable: it is compiled into the daemon, dockerd exposes no capability flag, and there is no daemon.json key for it. So unlike most premises in this rule set, this one holds unconditionally rather than by the default-posture assumption. Verified on Docker 29.1.3 — CapEff 00000000a80425fb, the 14 above, NET_RAW included. (The 11-capability default without NET_RAW belongs to Podman, which is out of scope — see ADR-020.)

Highlights of what the defaults grant:

  • NET_RAW — open raw/AF_PACKET sockets; enables ARP spoofing and packet injection on attached networks
  • NET_BIND_SERVICE — bind to privileged ports (< 1024)
  • SETUID / SETGID — change process UID/GID; required for gosu/su-exec user-switch entrypoints
  • CHOWN / DAC_OVERRIDE / FOWNER / FSETID — bypass file ownership and permission checks
  • MKNOD — create device nodes via mknod(2) (cannot create device nodes the device cgroup denies access to)
  • SETFCAP — set file capabilities on binaries inside the container

Most containerized applications need none of these. Retaining them expands the attack surface that a compromised container can leverage in chained exploits.

Fix

Drop all capabilities and add back only the specific ones your application requires:

services:
  web:
    image: nginx:1.27-alpine
    cap_drop:
      - ALL
    cap_add:
      - CHOWN            # entrypoint chowns /var/cache/nginx at startup
      - SETUID           # ...then drops the workers to the nginx user
      - SETGID

Verified against Docker 29.1.3: this container reaches running, serves a request, and its worker processes still run as nginx rather than root. The capability list is image-specific — nginx:1.27-alpine needs these three, and another image will need a different set, which is what Determining required capabilities below is for.

Do not copy cap_add: [NET_BIND_SERVICE] here as a starting point. It is inert for this image — under Docker's per-container network namespaces a low port needs no capability since 20.10 — and on its own it leaves the entrypoint's chown failing, which crash-loops the container:

nginx: [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)

That is the CHOWN row of this page's own symptom table.

Common capabilities that specific workloads may need: - NET_BIND_SERVICE — binding to privileged ports (< 1024) under network_mode: host; with Docker's default per-container network namespaces, low ports need no capability since Docker 20.10 (ip_unprivileged_port_start=0) - CHOWN / DAC_OVERRIDE / FOWNER / SETUID / SETGID — entrypoints that switch users (gosu, su-exec) or fix volume ownership at startup (postgres, redis) - NET_RAW — raw/AF_PACKET sockets: packet capture, ARP/DHCP watching, and ping binaries that don't use ICMP datagram sockets (tool-dependent — busybox's needs it, others don't)

Compatibility

cap_drop: [ALL] is incompatible with images whose entrypoints assume default capabilities. Common breakages:

  • gosu / su-exec user-switching entrypoints (postgres, redis, mysql, valkey) — need SETUID, SETGID, and usually CHOWN + DAC_OVERRIDE + FOWNER for volume initialization.
  • tini / dumb-init wrappers running as PID 1 with signal forwarding — usually fine, but check.
  • Init-style processes that fork helpers (cron, sendmail) — typically need SETUID + SETGID.

Determining required capabilities

There is no trustworthy registry mapping images to required capabilities — the required set depends on the image's entrypoint and your configuration (bind-mount ownership, user: overrides, network mode), so it has to be determined per service. The reliable method is drop-and-observe:

  1. Start from nothing. Apply cap_drop: [ALL] with no cap_add, then docker compose up. Many services — static binaries, images that already run as a non-root user — work with no capabilities at all.
  2. Verify function, not just startup. A running, "healthy" container is not proof the required set is complete: applications routinely catch a capability failure and continue with that feature disabled, so the damage shows up only in the logs or as a feature quietly not working. (Real example: a home-automation stack under cap_drop: [ALL] booted clean and served its UI, while its DHCP device-discovery integration died silently — the only trace was Cannot watch for dhcp packets: [Errno 1] Operation not permitted in the logs, until NET_RAW was restored.) After startup, read the full container logs for Operation not permitted / permission denied, then exercise the service's actual functionality — background behaviors included (device discovery, scheduled jobs, notifications, transcoding), not just the landing page. Healthchecks and orchestrator rollback gates won't catch healthy-but-degraded: a monitoring agent that loses a collector to a missing capability (e.g. per-process metrics without SYS_PTRACE) keeps serving its dashboard while an entire metrics family quietly disappears.
  3. Read the failure. A missing capability almost always surfaces as Operation not permitted (EPERM) — occasionally Permission denied (EACCES) — in the container logs, naming the operation that failed. Map the message back to a capability. Every mapping below is re-proven on each CI run (scripts/validate_rule_premises.py: the operation must fail under cap_drop: [ALL] and succeed with only the mapped capability added), with the busybox wordings asserted verbatim in those checks. The coreutils variants were captured from a live Debian container but are not CI-asserted, and application-level wordings (DHCP watcher, vault, Elasticsearch) are as those applications report them; exact wording varies between busybox, coreutils, and glibc builds of the same tool.
Symptom in logs (verbatim) Operation Capability to add
chown: /data: Operation not permitted (busybox) · chown: changing ownership of '/data': Operation not permitted (coreutils) ownership fixup at startup CHOWN, often plus DAC_OVERRIDE + FOWNER
chmod: /data: Operation not permitted (busybox) · chmod: changing permissions of '/data': Operation not permitted (coreutils) — target owned by another uid permission fixup on foreign-owned files FOWNER
su: can't set groups: Operation not permitted — likewise su-exec/gosu setuid/setgid failures entrypoint switches users SETUID + SETGID
nc: bind: Permission denied / bind failure on a port below 1024 — in practice only under network_mode: host or a hardened ip_unprivileged_port_start; in a container's own network namespace low ports need no capability (Docker 20.10+) privileged-port bind NET_BIND_SERVICE
ping: permission denied (are you root?) (busybox; other ping builds work capless via ICMP datagram sockets) · Cannot watch for dhcp packets: [Errno 1] Operation not permitted raw sockets: ping, DHCP/ARP watching, packet capture NET_RAW
mknod: /dev/foo: Operation not permitted device-node creation MKNOD
ip: RTNETLINK answers: Operation not permitted interface, route, or firewall changes (VPN and router containers) NET_ADMIN
renice: setpriority: Permission denied raising scheduling priority (media servers, realtime audio) SYS_NICE
date: can't set date: Operation not permitted setting the system clock (chrony, ntpd) SYS_TIME
kill: can't kill pid 1: Operation not permitted a root supervisor signaling children that run as another uid KILL
mlockall: Operation not permitted — surfaced by apps as vault's Failed to lock memory or Elasticsearch's Unable to lock JVM Memory; often silent: the service runs, the swap-protection is simply absent locking memory to keep secrets out of swap IPC_LOCK
  1. Add back only the capability implicated, then repeat — including the functional verification in step 2, not just the boot — until the service is fully working. Add one at a time — adding a batch hides which ones were actually needed. Never "fix" a failure with privileged: true or by skipping cap_drop: [ALL] entirely; that trades one MEDIUM finding for a worse posture (CL-0002).

The result is configuration-specific: changing a bind mount's ownership, the user: directive, or the image major version can change the required set, so re-verify after those changes.

Auditing tools

When log messages are too vague to map, observe the container directly:

  • capable (from BCC; bpftrace ships an equivalent capable.bt) traces cap_capable() checks kernel-wide, printing which process checked which capability. Run it on the host while exercising the container's startup and main workflows. It shows checks, not verdicts — there is no granted/denied column, and the kernel checks some capabilities speculatively for operations the application tolerates failing — so treat its output as candidates and confirm each one with a drop-and-observe run rather than adding everything it prints.
  • docker diff <container> lists files created or changed in the container's writable layer — volumes and bind mounts never appear in it. Run it against the service before hardening: ownership or permission churn on non-volume paths reveals startup fixups that will need CHOWN/FOWNER once capabilities are dropped. The common fixup that targets a volume is exactly what it cannot see; for that, read the entrypoint or the logs (steps 2–3).
  • Read the entrypoint. Most official images publish their entrypoint script; a gosu/su-exec invocation or an explicit chown there tells you the required set before you ever start the container.

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
T1040 Network Sniffing Credential Access (Enterprise/Linux, not on the Containers matrix)
T1557 Adversary-in-the-Middle Credential Access (Enterprise/Linux, not on the Containers matrix)
T1548.001 Abuse Elevation Control Mechanism: Setuid and Setgid Privilege Escalation (Enterprise/Linux, not on the Containers matrix)

See also

  • CL-0011 — dangerous capabilities added back via cap_add
  • CL-0002privileged: true (functional superset of all capabilities)