Skip to content

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: _cl0026 in scripts/validate_rule_premises.py — a container's memory.max is max and its cpu.max is max 100000 by 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 .001 Compute 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

  1. Run the service without limits and exercise its real workload, including the expensive paths — a backup, a re-index, a bulk import.
  2. Read the peak:

docker stats --no-stream <container>

  1. 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.
  2. 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)