diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 09e51f34..dd734bd0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -29,6 +29,7 @@ artwork. The ribbon and menu icons stay original drawings. | `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests | | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | | `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang | +| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | | `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | | `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | diff --git a/crates/media/src/audio.rs b/crates/media/src/audio.rs index 4cfbe637..80fe0b43 100644 --- a/crates/media/src/audio.rs +++ b/crates/media/src/audio.rs @@ -77,6 +77,7 @@ pub fn decode(bytes: &Bytes) -> Result { Some(r) => r, None => contained(|| decode_symphonia(bytes, "mkv")), }, + Container::Mp4 if !crate::mp4guard::tables_fit(bytes) => Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())), c => contained(|| decode_symphonia(bytes, c.name())), } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index d31b5d61..95150868 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -19,6 +19,7 @@ pub mod audio; pub mod clip; pub mod feed; +mod mp4guard; pub mod player; pub mod probe; pub mod video; diff --git a/crates/media/src/mp4guard.rs b/crates/media/src/mp4guard.rs new file mode 100644 index 00000000..ff8e8613 --- /dev/null +++ b/crates/media/src/mp4guard.rs @@ -0,0 +1,76 @@ +//! Checks an MP4's box tree before symphonia reads it. Symphonia's MP4 reader reserves memory +//! from the entry counts in the sample tables (and reads each sample whole at its `stsz` size) +//! without checking them against the file, so a damaged count asks for gigabytes and the process +//! aborts — an allocation failure cannot be caught. A file passes when every box fits inside its +//! parent, every table's entries fit inside its box and every sample fits inside the file. + +/// Boxes whose payload is a sequence of boxes. +const CONTAINERS: [&[u8; 4]; 11] = [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"edts", b"dinf", b"udta", b"mvex", b"moof", b"traf"]; + +/// Whether symphonia can be given `b` (an MP4) without a damaged size reaching an allocation. +pub(crate) fn tables_fit(b: &[u8]) -> bool { + boxes_fit(b, b.len() as u64, 0) +} + +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64) +} + +fn boxes_fit(b: &[u8], file_len: u64, depth: u32) -> bool { + if depth > 16 { + return false; + } + let mut at = 0usize; + while b.len() - at >= 8 { + let Some(size32) = be32(b, at) else { return false }; + let kind = &b[at + 4..at + 8]; + let (header, size) = match size32 { + 0 => (8, (b.len() - at) as u64), + 1 => match b.get(at + 8..at + 16) { + Some(s) => (16, u64::from_be_bytes(s.try_into().unwrap_or_default())), + None => return false, + }, + n => (8, n), + }; + if size < header as u64 || size > (b.len() - at) as u64 { + // A box that runs past the end of the file is fine at the top level (the file was cut + // short; symphonia stops there), but never inside a parent. + return depth == 0 && !matches!(kind, b"moov" | b"moof"); + } + let body = &b[at + header..at + size as usize]; + let ok = if CONTAINERS.iter().any(|c| c.as_slice() == kind) { + boxes_fit(body, file_len, depth + 1) + } else if kind == b"meta" { + // ISO `meta` is a full box (4 bytes of version and flags first); QuickTime's is not. + let skip = if be32(body, 0) == Some(0) { 4 } else { 0 }; + boxes_fit(&body[skip.min(body.len())..], file_len, depth + 1) + } else { + table_fits(kind, body, file_len) + }; + if !ok { + return false; + } + at += size as usize; + } + true +} + +/// A sample table (a full box: version and flags, then a count and that many entries). +fn table_fits(kind: &[u8], body: &[u8], file_len: u64) -> bool { + let entry = match kind { + b"stts" | b"ctts" | b"co64" => 8, + b"stsc" => 12, + b"stco" | b"stss" => 4, + b"stsz" => { + let (Some(size), Some(count)) = (be32(body, 4), be32(body, 8)) else { return false }; + if size != 0 { + return size <= file_len; + } + let Some(entries) = body.get(12..) else { return false }; + return count <= entries.len() as u64 / 4 + && entries.as_chunks::<4>().0.iter().take(count as usize).all(|s| u32::from_be_bytes(*s) as u64 <= file_len); + } + _ => return true, + }; + be32(body, 4).is_some_and(|count| count <= body.len().saturating_sub(8) as u64 / entry) +} diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index f17803d7..cf5de96a 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -58,6 +58,9 @@ pub fn probe(bytes: &Bytes) -> Result { }; if info.audio.is_none() && info.video.is_none() || info.audio.as_ref().is_some_and(|a| a.sample_rate == 0) { // Plain audio files (and anything our demuxers didn't describe): symphonia's probe. + if c == Container::Mp4 && !crate::mp4guard::tables_fit(bytes) { + return Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())); + } let (codec, rate, channels, frames) = std::panic::catch_unwind(|| crate::audio::probe_symphonia(bytes, c.name())) .ok() .flatten() diff --git a/crates/media/tests/decode.rs b/crates/media/tests/decode.rs index b2afef9c..8393caa1 100644 --- a/crates/media/tests/decode.rs +++ b/crates/media/tests/decode.rs @@ -372,3 +372,18 @@ fn damaged_media_neither_panics_nor_hangs() { } } } + +/// MP4s whose sample tables were damaged by a mutation fuzzer (from `tone.m4a`, so CC0 like it): +/// one claims billions of `stsc` entries, one a 4 GB sample. Symphonia reserved that much memory +/// up front and the process aborted; now they are corrupt files. +#[test] +fn mp4_sample_tables_that_overrun_the_file_are_corrupt() { + for name in ["damaged/stsc-count.m4a", "damaged/stsz-size.m4a"] { + let b = fixture(name); + assert!(matches!(audio::decode(&b), Err(MediaError::Corrupt(_))), "{name}"); + let _ = probe::probe(&b); + } + for name in ["tone.m4a", "tone-alac.m4a", "clip-h264.mp4", "clip-h264.mov", "clip-hevc.mp4"] { + assert!(audio::decode(&fixture(name)).is_ok() || name.starts_with("clip"), "{name} still decodes"); + } +} diff --git a/crates/media/tests/fixtures/damaged/stsc-count.m4a b/crates/media/tests/fixtures/damaged/stsc-count.m4a new file mode 100644 index 00000000..06cb3580 Binary files /dev/null and b/crates/media/tests/fixtures/damaged/stsc-count.m4a differ diff --git a/crates/media/tests/fixtures/damaged/stsz-size.m4a b/crates/media/tests/fixtures/damaged/stsz-size.m4a new file mode 100644 index 00000000..1e76492a Binary files /dev/null and b/crates/media/tests/fixtures/damaged/stsz-size.m4a differ