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.
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.