CL-0026: No resource limits — memory and CPU exhaustion¶
Severity: MEDIUM
Derivation (see severity model):
- Baseline: A — the attacker already has code execution in this container, as the workload uid
- Precondition: Removes a mitigation — the missing limit grants no primitive of its own; it deletes the cgroup ceiling that would have contained a runaway or compromised workload, widening the blast radius of exhaustion rather than supplying it. (The named-primitive test: nothing here is a nameable primitive, so this is Removes a mitigation, not Second flaw.)
- Impact: Host — an unbounded container exhausts host memory or CPU, and the host-global OOM killer takes down host processes, the daemon and neighbouring containers alike
- Qualifier/modifier: availability-only — the impact is denial of service and stolen compute, with no confidentiality or integrity loss
- Derived: Removes a mitigation × Host + availability-only = MEDIUM
- Shipped: MEDIUM
- Evidence:
_cl0026inscripts/validate_rule_premises.py— a container'smemory.maxismaxand itscpu.maxismax 100000by default, and both become bounded once a limit is set. Re-proven against a live daemon on every CI run
What it detects¶
A service that declares no memory limit, no CPU limit, or neither.
| Limit | Satisfied by |
|---|---|
| Memory | mem_limit, or deploy.resources.limits.memory |
| CPU | cpus, cpu_quota, or deploy.resources.limits.cpus |
The message names which of the two is missing, so a service that bounds memory but not CPU gets a finding that says so.
Reservations do not satisfy this rule. mem_reservation and cpu_shares
express priority under contention — they are soft targets the kernel aims for,
not ceilings it enforces. A service with only a reservation still has an
unbounded hard limit and is still flagged.
Neither does a non-positive value. Docker reads --memory 0 and --cpus 0
as unlimited, so mem_limit: 0 is an unbounded container wearing the syntax
of a bounded one. The key being present is not enough; the value has to bound
something. A bare ${VAR} interpolation is treated as a limit — its value is
unknowable from the file, and assuming the worst would fire on every
parameterised compose file. A defaulted interpolation is not unknowable:
mem_limit: "${MEM:-0}m" ships 0m, which Docker reads as unlimited, so it
fires. Previously any dollar-bearing value that failed to parse as a quantity
was credited as a limit, which let exactly that spelling through.
Why it matters¶
Docker imposes no default memory or CPU cap. Verified on Docker 29.1.3: a container started with no limits reports
memory.max = max
cpu.max = max 100000 # "max" quota — the period is set, the ceiling is not
and both become finite the moment --memory or --cpus is passed. So the
absence this rule flags is genuinely Docker's default rather than a hardening
preference — the bar the premise validator exists to enforce.
That matters in two different directions:
- Compute theft. Cryptomining is the most common opportunistic payload
dropped into a compromised container, and it is CPU-bound. A memory limit does
not slow it down at all; only a CPU limit bounds what an attacker can steal
from the host and from the workloads sharing it. (MITRE ATT&CK
T1496 Resource Hijacking, and
its
.001Compute Hijacking sub-technique.) - Denial of service. A memory leak or a deliberate allocation loop in one container reaches the host's OOM killer, which then picks a victim by its own heuristics — frequently not the offending container. (T1499 Endpoint Denial of Service.)
Neither is an isolation break, which is why this is MEDIUM rather than HIGH: the attacker gains no privilege and reads no data. What they gain is everyone else's resources.
Fix¶
Set both limits, sized from what the workload actually uses at steady state:
services:
api:
image: ghcr.io/acme/api:2.4.0
deploy:
resources:
limits:
memory: 512M
cpus: '0.50'
Compose v2 honours deploy.resources.limits outside Swarm, so this form works
for a plain docker compose up. The older v2 spellings are equivalent:
mem_limit: 512m
cpus: 0.50
Sizing the limits¶
- Run the service without limits and exercise its real workload, including the expensive paths — a backup, a re-index, a bulk import.
- Read the peak:
docker stats --no-stream <container>
- Set the memory limit above the observed peak with headroom (a common starting point is peak + 50%), and the CPU limit at the concurrency the service actually needs.
- Watch for the failure modes below, and raise rather than remove.
Reading the failure¶
A container that hits its memory limit is killed by the kernel, not asked to stop — so the symptom is an abrupt disappearance rather than a log line from the application:
| Symptom | What happened | Action |
|---|---|---|
Container exits with code 137, State.OOMKilled: true in docker inspect |
the cgroup's memory limit was reached | raise memory, or find the leak — the process got no chance to log |
| Application logs end mid-request with no error | same — SIGKILL leaves no shutdown path | as above |
Service slows steadily but never dies, cpu.stat throttled_usec climbing |
the CPU limit is throttling it | raise cpus; CPU limits throttle rather than kill, so this degrades instead of crashing |
Confirm which limit is biting before changing anything:
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container>
docker exec <container> cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max
The asymmetry is worth remembering: memory limits kill, CPU limits throttle. An over-tight memory limit takes the service down; an over-tight CPU limit makes it slow. Size memory with more headroom than CPU.
When to suppress¶
- A workload whose whole purpose is to use the machine — a batch job, a build
runner, a transcoder on a dedicated host. Suppress with a
reason:naming the workload and the host it owns. - A single-service host where there is nothing else to starve. Note that the host's own daemons are still something to starve.
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 |
|---|---|
| T1496 Resource Hijacking | Impact |
| T1499 Endpoint Denial of Service | Impact |
See also¶
- CL-0007 — read-only root filesystem (the other containment control that limits what a compromised container can do rather than what it can reach)