Skip to content

Infinite loop in MP4 box parser (size==0 child box) → worker CPU exhaustion DoS #1808

Description

@arnoldvdui

Summary
A crafted MP4 file containing a child box whose size field is zero triggers an infinite loop in the MP4 box parser (ngx_rtmp_mp4_parse(), in ngx_rtmp_mp4_module.c). An nginx worker spins at ~100% CPU and never returns to the event loop, blocking every connection served by that worker.

Single-worker deployment: one malicious play request = whole RTMP service unusable.
Multi-worker deployment (e.g. Ubuntu default of 6 workers): one request locks one worker; N concurrent requests lock all workers, rendering the service unavailable (verified with HTTP probe failure).
Affected Versions
v1.2.2 tag — confirmed present in the released parser code
master (commit 6c7719d, 2024-12-24) — confirmed present
Every distribution package built from these versions, e.g. Ubuntu libnginx-mod-rtmp 1:1.2.2+dfsg-7build3 (nginx 1.28.3) — verified exploitable
The affected function ngx_rtmp_mp4_parse() has not been modified since commit 8acacd0 (2014-04-24), so the bug has existed for at least 12 years in every released version in between.
Root Cause
In ngx_rtmp_mp4_parse() (box parsing loop, ~line 1270):

while (pos != last) {
if (pos + 8 > last) {
/* "mp4: too small box" */ return NGX_ERROR;
}
hdr = (uint32_t ) pos;
size = ngx_rtmp_r32(hdr[0]);
tag = hdr[1];
if (pos + size > last) {
/
"mp4: too big box" / return NGX_ERROR;
}
...
b->handler(s, pos + 8, pos + size);
pos += size; /
size == 0 → pos never advances → infinite loop */
}
When size == 0:

The bounds check pos + size > last is pos + 0 > last, which is never true, so it passes.
The box handler is invoked with an empty range (pos + 8, pos + 0).
pos += 0 does not advance the cursor, so the loop condition pos != last stays true forever.
The parser spins, consuming one CPU core and (in debug builds) flooding the error log with mp4: box 'trak' lines.

Note on ISO-BMFF semantics
In ISO-BMFF, size == 0 means "box extends to the end of the file". The module does handle this case at the top level in ngx_rtmp_mp4_init() (it substitutes the remaining file size — commit 289ee42, 2013-06-01, "added handling mp4 box with zero size"). But the child-box parse loop does not — the 2013 fix was incomplete. A top-level size == 0 box is therefore cleanly handled, while a child box with size == 0 reaches the infinite loop.

Proof of Concept
16-byte file, moov_zero_trak.mp4 (a moov box of size 16 containing a trak child box with size 0):

00 00 00 10 6d 6f 6f 76 00 00 00 00 74 72 61 6b
^size=16 ^tag=moov ^size=0 ^tag=trak
Place it in the VOD directory and play it over RTMP:

ffmpeg -i rtmp:////moov_zero_trak.mp4
Observed behavior
Custom build (nginx 1.24.0 + module, --with-debug -O0):

Worker CPU rises to ~33% and stays in R state; never returns to event loop.
Debug log grows from 0 to 9.6M lines, all mp4: box 'trak' (direct evidence of the loop).
A subsequent legitimate play request fails with "Cannot read RTMP handshake response" — the worker is fully blocked for all connections.
Ubuntu packaged build (nginx 1.28.3 + libnginx-mod-rtmp 1:1.2.2+dfsg-7build3):

1 request locks 1 of 6 workers: state S→R, CPU 42%→81%, CPU time keeps growing.
6 concurrent requests lock all 6 workers → full service DoS (HTTP probe to the same nginx fails).
Reproducible on restart: single request again locks one worker.
Impact
Type: Denial of Service (availability). No memory corruption.
Attack surface: The attacker must be able to place a crafted .mp4 file into the directory served by the RTMP play directive (local filesystem write access), then trigger a play. This is a local-file + trigger DoS, not a remotely-triggerable arbitrary-file bug.
Amplification: In multi-worker deployments, N concurrent play requests (N = worker count) exhaust every worker, taking the whole service offline.
Suggested Fix (minimal)
Add a minimum-size guard in the parse loop:

if (size < 8 || pos + size > last) {
ngx_log_error(NGX_LOG_ERR, s->connection->log, ngx_errno,
"mp4: too small box '%*s': size=%uz", 4, &tag, size);
return NGX_ERROR;
}
size < 8 rejects both the zero-size loop and truncated 1..7-byte boxes in one check. The same guard pattern already exists at the top of the loop for the header (pos + 8 > last).

Validation: a libFuzzer campaign with this fix ran 149,249,870 executions / ~30 min, 0 crashes, 0 timeouts, 0 leaks (production-fidelity harness that mimics the mmap zero-padded tail). No regressions observed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions