From e01ea00cdda610a853d68f9dfbf7644dcf130cba Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:13:28 -0500 Subject: [PATCH] audio-io: cap decoder packet samples and MP4 audio timing runs (#1332) * fix(audio-io): bound packet samples and MP4 audio timing runs * address review: decide MP4 audio timing bound from the stsd entry and bound PCM packets by their byte size * address review: the last stsd in a stbl decides whether the stts bound applies --- crates/audio-io/src/mp4.rs | 61 ++++++++++++++++++++++++--- crates/audio-io/src/symph.rs | 16 ++++++- crates/audio-io/tests/it/malformed.rs | 59 +++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/crates/audio-io/src/mp4.rs b/crates/audio-io/src/mp4.rs index 06e68ddc..1cc79f86 100644 --- a/crates/audio-io/src/mp4.rs +++ b/crates/audio-io/src/mp4.rs @@ -4,6 +4,12 @@ use crate::pcm::{be_u32, be_u64}; use crate::{AudioError, Result}; +/// The most `stts` entries a sound track may have. Symphonia finds each packet's timestamp by +/// scanning the table from its start, so a table of one-sample runs makes decoding quadratic in +/// the packet count. Audio encoders write one run per packet length (a handful of entries); +/// this bound keeps the worst case at a few hundred million steps. +const MAX_AUDIO_STTS_ENTRIES: u32 = 1 << 14; + #[derive(Clone, Copy)] enum Level { Top, @@ -11,7 +17,11 @@ enum Level { Trak, Mdia, Minf, - Stbl, + /// `audio`: the sample entry is an audio format, so symphonia will decode its packets (whatever the + /// handler says: it builds codec parameters from `stsd` alone). + Stbl { + audio: bool, + }, Udta, Meta, Ilst, @@ -55,13 +65,20 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result { (Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?, (Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?, (Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?, - (Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?, + (Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl { audio: entry_is_audio(b, body, inner) })?, (Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?, (Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?, - (Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?, - (Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?, - (Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?, - (Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?, + (Level::Stbl { audio }, b"stts") => { + let fits = table(b, body + 4, 8, inner)?; + if audio && be_u32(b, body + 4).is_some_and(|n| n > MAX_AUDIO_STTS_ENTRIES) { + return Err(AudioError::Malformed("MP4 audio timing table has more runs than packets could need".into())); + } + fits + } + (Level::Stbl { .. }, b"co64") => table(b, body + 4, 8, inner)?, + (Level::Stbl { .. }, b"stsc") => table(b, body + 4, 12, inner)?, + (Level::Stbl { .. }, b"stco") => table(b, body + 4, 4, inner)?, + (Level::Stbl { .. }, b"stsz") => sample_sizes(b, body, inner)?, (Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => { return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind)))); } @@ -75,6 +92,38 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result { Ok(true) } +/// The sample entry formats symphonia reads as audio (the list `stsd` is matched against in +/// symphonia-format-isomp4 0.6); every other format, video and subtitles included, has no audio packets. +const AUDIO_FORMATS: [&[u8; 4]; 18] = [ + b"mp4a", b"alac", b"ac-3", b"ec-3", b"fLaC", b"Opus", b".mp3", b"lpcm", b"wave", b"alaw", b"ulaw", b"raw ", b"sowt", b"twos", b"in24", b"in32", + b"fl32", b"fl64", +]; + +/// Whether the `stbl` atom whose children lie in `pos..end` describes audio: its `stsd` may come before +/// or after the timing tables, so the children are walked once more for it. Symphonia keeps the last +/// `stsd` it reads, so the last one decides. +fn entry_is_audio(b: &[u8], mut pos: usize, end: usize) -> bool { + let mut audio = false; + while pos < end { + let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return audio }; + let head = if size == 1 { 16 } else { 8 }; + if kind == b"stsd" { + // Version and flags, the entry count, then the first entry's size and format. + audio = b.get(pos + head + 12..pos + head + 16).is_some_and(|f| AUDIO_FORMATS.iter().any(|a| a.as_slice() == f)); + } + let len = match size { + 0 => return audio, + 1 => be_u64(b, pos + 8).unwrap_or(0), + n => u64::from(n), + }; + match usize::try_from(len).ok().filter(|&n| n >= head).and_then(|n| pos.checked_add(n)) { + Some(next) => pos = next, + None => return audio, + } + } + audio +} + /// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for /// all of them first. False if they run past the table's atom (`end`), where symphonia stops. fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result { diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index ca96b964..58981f6f 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -22,6 +22,11 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat /// takes its frame length from the file (encoders use 4096). const MAX_PACKET_FRAMES: u64 = 1 << 20; +/// The most samples (frames × channels) a packet may declare: the PCM decoder allocates that +/// many for its output up front, so a CAF with thousands of channels and a long packet could +/// otherwise claim gigabytes before a sample is decoded (64 MiB of f32). +const MAX_PACKET_SAMPLES: u64 = 1 << 24; + /// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia /// probe scans. const MAX_PREFIX_SCAN: usize = 1 << 20; @@ -508,7 +513,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf } let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?; let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0)); - if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) { + let ch = u64::from(info.channels); + if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES || n.saturating_mul(ch) > MAX_PACKET_SAMPLES) + { return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); } let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &decoder_options()).map_err(map_err)?; @@ -531,6 +538,13 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf if packet.track_id != track_id { continue; } + // The declared frames per packet are not the packet's size: a container supplies that on its + // own, and the PCM decoder sizes its output by the bytes it is given. + if let Some(bytes_per_sample) = params.bits_per_coded_sample.map(|b| u64::from(b / 8)).filter(|&n| n > 0) + && packet.data.len() as u64 / bytes_per_sample > MAX_PACKET_SAMPLES + { + return Err(AudioError::Malformed("a packet holds far more samples than any codec uses".into())); + } let decoded = match decoder.decode(&packet) { Ok(d) => d, Err(SymError::DecodeError(m)) => { diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index fd23f922..67cb6998 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -321,7 +321,7 @@ fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec) -> Vec) - }; let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom"); let offset = (ftyp.len() + moov(0).len() + 8) as u32; - let pcm: Vec = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect(); + let pcm: Vec = (0..frames as i16).flat_map(|i| i.wrapping_mul(40).to_le_bytes()).collect(); [ftyp, moov(offset), atom(b"mdat", &pcm)].concat() } @@ -373,6 +373,63 @@ fn packets_longer_than_any_codec_uses_are_refused() { for entry in [lpcm(1 << 21), alac(1 << 21)] { assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets"))); } + // Frames and channels multiply into the decoder's packet buffer; an ordinary stereo stream + // with long packets stays well inside the bound. + let wide = |channels: u32, frames: u32| { + let fields = [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[channels, 0x7f00_0000, 16, 4, 2 * channels, frames])].concat(); + sample_entry(b"lpcm", 2, &fields, &[]) + }; + assert!(decode(&mp4(480, &wide(2, 1 << 13), |_, a| a), None).is_ok()); +} + +#[test] +fn an_audio_timing_table_with_a_run_per_sample_is_bounded() { + let pcm = sample_entry(b"sowt", 0, &[], &[]); + // One stts run per sample: symphonia scans the runs from the start for every packet. + let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat()); + let frames = 1 << 14; + let (_, buf) = decode(&mp4(frames, &pcm, |k, a| if k == b"stts" { runs(frames) } else { a }), None).unwrap(); + assert_eq!(buf.frames(), frames as usize, "a table at the bound still decodes"); + let f = mp4(frames + 1, &pcm, |k, a| if k == b"stts" { runs(frames + 1) } else { a }); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "one run past the bound is refused"); +} + +#[test] +fn the_audio_timing_bound_follows_the_sample_entry_not_the_handler() { + let pcm = sample_entry(b"sowt", 0, &[], &[]); + let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat()); + // an audio sample entry under a video handler is still decoded as audio by symphonia + let video_handler = atom(b"hdlr", &[0u32, 0, u32::from_be_bytes(*b"vide"), 0, 0, 0, 0].map(u32::to_be_bytes).concat()); + let frames = (1 << 14) + 1; + let f = mp4(frames, &pcm, |k, a| match k { + b"stts" => runs(frames), + b"hdlr" => video_handler.clone(), + _ => a, + }); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the handler does not exempt an audio entry"); +} + +#[test] +fn the_last_sample_entry_decides_whether_the_timing_bound_applies() { + let pcm = sample_entry(b"sowt", 0, &[], &[]); + let video = sample_entry(b"avc1", 0, &[], &[]); + let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat()); + let audio_stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &pcm].concat()); + let frames = (1 << 14) + 1; + // A video `stsd` first and an audio one after it: symphonia keeps the last, so it decodes audio. + let f = mp4(frames, &video, |k, a| if k == b"stts" { [audio_stsd.clone(), runs(frames)].concat() } else { a }); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the later audio entry counts"); +} + +#[test] +fn a_pcm_packet_longer_than_its_declared_frames_is_refused_before_decoding() { + let pcm = sample_entry(b"sowt", 0, &[], &[]); + // one `stsz` sample of 2 * 2^24 + 2 bytes (just over the packet bound) in a file that holds it + let big = 2 * (1usize << 24) + 2; + let stsz = atom(b"stsz", &[0u32, big as u32, 1].map(u32::to_be_bytes).concat()); + let mut f = mp4(1, &pcm, |k, a| if k == b"stsz" { stsz.clone() } else { a }); + f.resize(f.len() + big, 0); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("packet holds far more samples"))); } /// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.