Stop the mp4 demuxer dropping tracks silently or inventing sample offsets

Three defects in the mp4:// read path, all of the same family: a damaged
source was remuxed minus a track, or with fabricated data, and the run
reported success.

Silent drops. Eight paths dropped a whole track on malformed input with no
report of any kind, so an mp4:// source missing its audio looked like a clean
run. Each now emits a tracing::warn! naming the track and the missing or
inconsistent table (tracing English is permitted in this crate; the numeric
error codes are unchanged). The non-A/V handler case is debug!, since skipping
a timecode or hint track is normal.

Fabricated offsets. sample_offsets ended with a `while offsets.len() <
sizes.len()` loop that packed unplaced samples after the last known offset.
Those samples have no known location, so the invented offsets made the reader
pull frame data from arbitrary file bytes — the exact "emit garbage" outcome
the stco/stsc presence guards refuse. It now returns the short list and the
caller drops the track.

Short stts. `durations.get(i).unwrap_or(0)` gave every sample past the end of a
short stts a duration of 0, collapsing the whole tail onto one timestamp. That
is the same degenerate timing the `durations.is_empty()` guard was written to
refuse, so the guard now refuses both cases.

Two shared test fixtures were internally inconsistent and only passed because
the reader was lenient: stsz declared 3 samples while stsc placed 1, and the
hostile-stsz fixture's stsc/stts covered a single sample. Both are now
consistent. The hostile fixture keeps its lying stsz count — that lie is what
it tests — but its stsc and stts now cover whatever count survives the
file_len bound, so it exercises the allocation bound rather than the
inconsistency guards.

Two new tests pin the new refusals by mutating the consistent fixture: an stsc
that places 1 of 3 samples, and an stts that covers 1 of 3.
This commit is contained in:
Matthew Jackson
2026-07-29 18:42:38 -07:00
parent a39045adf1
commit a1304f9e78
+137 -15
View File
@@ -108,6 +108,7 @@ impl<R: Read + Seek> Mp4Reader<R> {
break; // bound track count so the per-track PID can't overflow u16 break; // bound track count so the per-track PID can't overflow u16
} }
let Some(mdia) = find_box(trak, b"mdia") else { let Some(mdia) = find_box(trak, b"mdia") else {
tracing::warn!(track = track_idx, "mp4: trak has no mdia, dropping track");
continue; continue;
}; };
let timescale = find_box(mdia, b"mdhd") let timescale = find_box(mdia, b"mdhd")
@@ -117,12 +118,15 @@ impl<R: Read + Seek> Mp4Reader<R> {
let language = find_box(mdia, b"mdhd").and_then(mdhd_language); let language = find_box(mdia, b"mdhd").and_then(mdhd_language);
let handler = find_box(mdia, b"hdlr").and_then(hdlr_type); let handler = find_box(mdia, b"hdlr").and_then(hdlr_type);
let Some(minf) = find_box(mdia, b"minf") else { let Some(minf) = find_box(mdia, b"minf") else {
tracing::warn!(track = track_idx, "mp4: mdia has no minf, dropping track");
continue; continue;
}; };
let Some(stbl) = find_box(minf, b"stbl") else { let Some(stbl) = find_box(minf, b"stbl") else {
tracing::warn!(track = track_idx, "mp4: minf has no stbl, dropping track");
continue; continue;
}; };
let Some(stsd) = find_box(stbl, b"stsd") else { let Some(stsd) = find_box(stbl, b"stsd") else {
tracing::warn!(track = track_idx, "mp4: stbl has no stsd, dropping track");
continue; continue;
}; };
@@ -133,7 +137,11 @@ impl<R: Read + Seek> Mp4Reader<R> {
channels, channels,
}) = parse_stsd(stsd) }) = parse_stsd(stsd)
else { else {
continue; // unrecognised sample entry — skip the track tracing::warn!(
track = track_idx,
"mp4: unrecognised stsd sample entry, dropping track"
);
continue;
}; };
// Build the stream model for this track. // Build the stream model for this track.
@@ -162,7 +170,13 @@ impl<R: Read + Seek> Mp4Reader<R> {
purpose: LabelPurpose::Normal, purpose: LabelPurpose::Normal,
label: String::new(), label: String::new(),
}), }),
_ => continue, // non-A/V handler _ => {
tracing::debug!(
track = track_idx,
"mp4: non-audio/video handler, skipping track"
);
continue;
}
}; };
// Per-sample tables. `stsz` is bounded by the remaining global budget; // Per-sample tables. `stsz` is bounded by the remaining global budget;
@@ -184,6 +198,11 @@ impl<R: Read + Seek> Mp4Reader<R> {
.or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true))) .or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true)))
.unwrap_or_default(); .unwrap_or_default();
if chunk_offsets.is_empty() { if chunk_offsets.is_empty() {
tracing::warn!(
track = track_idx,
samples = n,
"mp4: stbl has samples but no stco/co64 chunk-offset table, dropping track"
);
// Samples exist but there is no chunk-offset table: the stbl is // Samples exist but there is no chunk-offset table: the stbl is
// malformed and every sample offset would resolve to file byte 0 // malformed and every sample offset would resolve to file byte 0
// (muxing header bytes as frame data). Drop the track rather than // (muxing header bytes as frame data). Drop the track rather than
@@ -192,16 +211,43 @@ impl<R: Read + Seek> Mp4Reader<R> {
} }
let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default(); let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default();
if stsc.is_empty() { if stsc.is_empty() {
tracing::warn!(
track = track_idx,
samples = n,
"mp4: stbl has samples but no stsc sample-to-chunk map, dropping track"
);
// No sample-to-chunk map: samples can't be placed against the chunk // No sample-to-chunk map: samples can't be placed against the chunk
// offsets (they would pack from byte 0). Drop the track rather than // offsets (they would pack from byte 0). Drop the track rather than
// emit header bytes as frame data — a valid stbl always has stsc. // emit header bytes as frame data — a valid stbl always has stsc.
continue; continue;
} }
let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc); let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc);
if offsets.len() < sizes.len() {
// The stsc passed the non-empty guard but does not place every
// sample. The unplaced tail has no real offset, so carrying the
// track would read frames from arbitrary file bytes.
tracing::warn!(
track = track_idx,
placed = offsets.len(),
samples = n,
"mp4: stsc places fewer samples than stsz declares, dropping track"
);
continue;
}
let durations = find_box(stbl, b"stts") let durations = find_box(stbl, b"stts")
.map(|b| parse_stts(b, n)) .map(|b| parse_stts(b, n))
.unwrap_or_default(); .unwrap_or_default();
if durations.is_empty() { if durations.len() < n {
// Absent OR short: `stts` is mandatory and must cover every sample
// (ISO/IEC 14496-12 §8.6.1). A short table gave every unmapped tail
// sample dur=0, collapsing the whole tail onto one instant — the
// same degenerate timing the absent case refuses, so refuse both.
tracing::warn!(
track = track_idx,
durations = durations.len(),
samples = n,
"mp4: stts does not cover every sample, dropping track"
);
// Samples exist but there is no decoding-time table: `stts` is // Samples exist but there is no decoding-time table: `stts` is
// mandatory in a valid stbl (ISO/IEC 14496-12 §8.6.1). Without it // mandatory in a valid stbl (ISO/IEC 14496-12 §8.6.1). Without it
// every sample would take dur=0 → all-zero, identical timestamps, // every sample would take dur=0 → all-zero, identical timestamps,
@@ -682,15 +728,11 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) ->
sidx += 1; sidx += 1;
} }
} }
// Any trailing samples with no chunk mapping: pack after the last offset. // Samples the stsc did not place have NO known location. Fabricating one by
while offsets.len() < sizes.len() { // packing after the last offset invents a position, and the frame is then read
let last = offsets.last().copied().unwrap_or(0); // from arbitrary file bytes — exactly the "emit garbage" outcome the stco/stsc
let last_sz = sizes // guards above refuse. Report the shortfall instead and let the caller drop
.get(offsets.len().saturating_sub(1)) // the track.
.copied()
.unwrap_or(0);
offsets.push(last.saturating_add(last_sz as u64));
}
offsets offsets
} }
@@ -1334,7 +1376,11 @@ mod tests {
p.extend_from_slice(&[0, 0, 0, 0]); p.extend_from_slice(&[0, 0, 0, 0]);
p.extend_from_slice(&1u32.to_be_bytes()); // count p.extend_from_slice(&1u32.to_be_bytes()); // count
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk // Deliberately large: the stsz COUNT is the lie under test, so the
// stsc and stts must still cover whatever count survives the file_len
// bound. Both are clamped to the real sample count when expanded, so
// this places every bounded sample without itself being degenerate.
p.extend_from_slice(&0xFFFFu32.to_be_bytes()); // samples_per_chunk
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
mp4_box(b"stsc", &p) mp4_box(b"stsc", &p)
}; };
@@ -1342,7 +1388,7 @@ mod tests {
let mut p = Vec::new(); let mut p = Vec::new();
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
p.extend_from_slice(&1u32.to_be_bytes()); // sample_count p.extend_from_slice(&0xFFFFu32.to_be_bytes()); // sample_count (see stsc)
p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
mp4_box(b"stts", &p) mp4_box(b"stts", &p)
}; };
@@ -1421,7 +1467,11 @@ mod tests {
p.extend_from_slice(&[0, 0, 0, 0]); p.extend_from_slice(&[0, 0, 0, 0]);
p.extend_from_slice(&1u32.to_be_bytes()); // count p.extend_from_slice(&1u32.to_be_bytes()); // count
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk // All 3 samples live in the single chunk. This MUST match the stsz
// count: an stsc placing fewer samples than stsz declares is a
// malformed stbl, and the reader now drops such a track rather than
// fabricating offsets for the unplaced tail.
p.extend_from_slice(&3u32.to_be_bytes()); // samples_per_chunk
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
mp4_box(b"stsc", &p) mp4_box(b"stsc", &p)
}; };
@@ -1503,6 +1553,78 @@ mod tests {
); );
} }
/// An `stsc` that PASSES the non-empty guard but places fewer samples than
/// `stsz` declares must drop the track. `sample_offsets` used to pack the
/// unplaced tail after the last known offset, inventing a position, so those
/// frames were read from arbitrary file bytes — the same "emit garbage"
/// outcome the stco/stsc presence guards exist to refuse.
///
/// Mutation check: restore the trailing `while offsets.len() < sizes.len()`
/// pack-after-last loop and this file indexes 3 samples instead of erroring.
#[test]
fn stsc_placing_fewer_samples_than_stsz_drops_the_track() {
use std::io::Cursor;
// audio_trak_missing(b"____") is the complete, consistent fixture: stsz=3
// and stsc samples_per_chunk=3. Knock samples_per_chunk down to 1 so the
// stsc is present and non-empty yet places only sample 1 of 3.
let mut trak = audio_trak_missing(b"____");
let stsc_tag = b"stsc";
let pos = trak
.windows(4)
.position(|w| w == stsc_tag)
.expect("fixture has an stsc");
// stsc payload: version+flags(4) entry_count(4) first_chunk(4)
// samples_per_chunk(4) — so samples_per_chunk starts 16 bytes past the tag.
let spc = pos + 4 + 12;
assert_eq!(
&trak[spc..spc + 4],
&3u32.to_be_bytes(),
"expected the consistent fixture's samples_per_chunk = 3"
);
trak[spc..spc + 4].copy_from_slice(&1u32.to_be_bytes());
let moov = mp4_box(b"moov", &trak);
let rd = Mp4Reader::from_reader(Cursor::new(moov), "short-stsc".into());
assert!(
rd.is_err(),
"an stsc that places 1 of 3 samples must drop the track; \
all-dropped → Mp4Invalid"
);
}
/// A SHORT `stts` — present and non-empty, but covering fewer samples than
/// `stsz` declares — must drop the track just like an absent one. The tail
/// samples took `dur = 0`, collapsing the whole tail onto a single timestamp,
/// which is the exact degenerate timing the absent-stts guard refuses.
///
/// Mutation check: weaken the guard back to `durations.is_empty()` and this
/// file indexes 3 samples whose last two share one timestamp.
#[test]
fn short_stts_drops_the_track_like_an_absent_one() {
use std::io::Cursor;
let mut trak = audio_trak_missing(b"____");
let pos = trak
.windows(4)
.position(|w| w == b"stts")
.expect("fixture has an stts");
// stts payload: version+flags(4) entry_count(4) sample_count(4) delta(4)
let sample_count = pos + 4 + 8;
assert_eq!(
&trak[sample_count..sample_count + 4],
&3u32.to_be_bytes(),
"expected the consistent fixture's stts sample_count = 3"
);
trak[sample_count..sample_count + 4].copy_from_slice(&1u32.to_be_bytes());
let moov = mp4_box(b"moov", &trak);
let rd = Mp4Reader::from_reader(Cursor::new(moov), "short-stts".into());
assert!(
rd.is_err(),
"an stts covering 1 of 3 samples must drop the track; \
all-dropped → Mp4Invalid"
);
}
/// Sanity companion: the SAME builder WITH both tables present yields a valid, /// Sanity companion: the SAME builder WITH both tables present yields a valid,
/// indexed single-track file — proving the two Err results above come from the /// indexed single-track file — proving the two Err results above come from the
/// missing table, not from some unrelated defect in the fixture builder. /// missing table, not from some unrelated defect in the fixture builder.