mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Media: refuse MP4s whose sample tables overrun the file before symphonia reserves memory for them
This commit is contained in:
@@ -26,6 +26,7 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
|
||||
| `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/*` (`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 |
|
||||
|
||||
@@ -77,6 +77,7 @@ pub fn decode(bytes: &Bytes) -> Result<Pcm> {
|
||||
Some(r) => r,
|
||||
None => 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 => decode_symphonia(bytes, c.name()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod audio;
|
||||
pub mod clip;
|
||||
pub mod feed;
|
||||
mod mp4guard;
|
||||
pub mod player;
|
||||
pub mod probe;
|
||||
pub mod video;
|
||||
|
||||
@@ -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<u64> {
|
||||
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)
|
||||
}
|
||||
@@ -58,6 +58,9 @@ pub fn probe(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
};
|
||||
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) =
|
||||
crate::audio::probe_symphonia(bytes, c.name()).ok_or_else(|| MediaError::Unsupported("this kind of media file".into()))?;
|
||||
info.audio = Some(AudioInfo { codec: codec_label(&codec), sample_rate: rate, channels, decodable: true });
|
||||
|
||||
@@ -325,3 +325,18 @@ fn player_plays_decoded_audio_through_the_mixer() {
|
||||
assert!((rms - 0.0884 * 0.5).abs() < 0.005, "{rms}");
|
||||
assert!((p.status(1).map(|s| s.position).unwrap_or(0.0) - 0.1).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user