CL-0005: Ports published on 0.0.0.0 (all interfaces)¶
Severity: MEDIUM
Derivation (see severity model):
- Baseline: A — the attacker already has code execution in this container, as the workload uid
- Precondition: Second flaw — an exposed port is only a compromise if the service behind it has an exploitable defect or a weak credential
- Impact: Single container — the service the port belongs to
- Qualifier/modifier: pre-foothold reach — no foothold anywhere is required to reach the port, which is what this modifier prices
- Derived: Second flaw × Single container + pre-foothold reach = HIGH
- Shipped: MEDIUM — override
detection-precision— the dangerous case is the binding that was not meant to be public, and the file does not say which is which. An exposed datastore is serious; an exposed web server is the design. Well-known datastore and admin ports are a usable proxy, so the path off this override is a split rather than a smarter matcher (ADR-020 Appendix B) - Evidence:
_cl0005— a bare published port binds all interfaces, verified live. Docker publishes ports by writing iptables rules directly, so the binding is reachable even where UFW or firewalld would deny it
References: - OWASP Docker Security Rule #5a - CIS Docker Benchmark 5.14 — Ensure that incoming container traffic is bound to a specific host interface
What it detects¶
Port mappings that don't specify a bind address or that bind to a wildcard, in both short syntax ("8080:80", "0.0.0.0:8080:80", "[::]:8080:80") and long syntax (published: without host_ip:, or with host_ip: "0.0.0.0" / host_ip: "::"). A bare short-syntax port with no colon ("80", 3001, a "3000-3005" range) is also flagged: Docker still publishes it, assigning a random (ephemeral) host port bound to all interfaces (docker compose config normalizes - "80" to a published target, and docker compose up binds it on 0.0.0.0). The long-syntax spelling of the same publish, a mapping with target: and no published:, is flagged the same way: Compose renders - target: 80 and - "80" to the identical entry. So is an ephemeral publish with an explicit wildcard or empty address ("0.0.0.0::80", "::81", "[::]::82", ":9000"). Bind it with 127.0.0.1::80 to keep the ephemeral port on localhost. A long-syntax host_ip: that is written but empty, null or not a string (including an empty ${VAR:-}) is not flagged: Compose refuses the file (invalid ip address:, must be a string), so no port is published.
A short-syntax port is split the way Docker's own parser splits it: the last field is the container port, the one before it the host port, and everything before that is the address, colons and all. That is how an unbracketed IPv6 address (":::8080:80", "0:0:0:0:0:0:0:0:8080:80") is read as an address, and why "::1:8080:80" is a loopback bind. The bind address is decided by parsing it, not by matching the spelling: every way of writing the unspecified address is the same address and publishes on every interface — 0.0.0.0, ::, [::], [::0], [0:0:0:0:0:0:0:0], [::ffff:0.0.0.0]. Specific bind addresses including loopback (127.0.0.1, [::1]) are not flagged, and a value that is not an address at all (a hostname) is not treated as a wildcard.
Why it matters¶
Docker publishes ports by manipulating iptables directly, bypassing host firewalls like UFW and firewalld. A port published without a bind address is accessible on all network interfaces, potentially exposing services to the public internet even when host firewall rules would otherwise block the traffic.
Fix¶
# Instead of (binds to all interfaces, bypasses host firewall):
ports:
- "8080:80"
# Bind to localhost only:
ports:
- "127.0.0.1:8080:80"
# Long syntax:
ports:
- target: 80
published: 8080
host_ip: "127.0.0.1"
If this service needs to be publicly accessible, place it behind a reverse proxy (Traefik, Caddy, nginx) that handles TLS termination, and bind only the reverse proxy's ports publicly.
For systemic mitigation, configure the DOCKER-USER iptables chain on the host so that Docker's bypass of UFW/firewalld becomes per-rule rather than per-service. This is documented in Docker and iptables. Per-service binding is whack-a-mole; the chain-level rule is the durable fix.
Swarm caveat¶
host_ip in the long syntax is not always honored under Swarm mode — Swarm uses an ingress mesh and may publish ports on all manager nodes regardless. If you're targeting Swarm, port publishing is a Swarm concern; this rule still flags the file but treat the fix as Swarm-specific.
When to suppress¶
- Public-facing service on a VPS where the cloud provider's firewall is the security boundary and
0.0.0.0is intended. Suppress with areason:naming the firewall layer (e.g.reason: "GCP firewall enforces ingress; UFW disabled by design").
ATT&CK coverage¶
No adversary technique maps to this rule. That is a finding about the rule, not an omission — see below.
It does enable T1190 Exploit Public-Facing Application, but enabling is not the same as being a technique: the misconfiguration widens the attack surface an adversary can reach rather than supplying a step they execute.
See also¶
- CL-0008 —
network_mode: host(port-publish controls don't apply at all)