diff --git a/crates/audio/src/read/mp4.rs b/crates/audio/src/read/mp4.rs index 06e68ddc9..41f00c219 100644 --- a/crates/audio/src/read/mp4.rs +++ b/crates/audio/src/read/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. Ports soundcraft #1332. +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/src/read/symph.rs b/crates/audio/src/read/symph.rs index e16c6e314..0b879c55a 100644 --- a/crates/audio/src/read/symph.rs +++ b/crates/audio/src/read/symph.rs @@ -27,6 +27,11 @@ use crate::{AudioBuffer, AudioError, AudioInfo, ChannelLayout, CodecId, Containe /// takes its frame length from the file (encoders use 4096). const MAX_PACKET_FRAMES: u64 = 1 << 20; +/// The most samples (frames x 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`). Ports soundcraft #1332. +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; @@ -615,7 +620,13 @@ impl Symph { let t = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?; let params = audio_params(t).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 channels = 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(channels) > MAX_PACKET_SAMPLES) + { return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); } let decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &decoder_options()).map_err(map_err)?; diff --git a/crates/audio/tests/audio/malformed.rs b/crates/audio/tests/audio/malformed.rs index d69fb6879..8a2a8a3c5 100644 --- a/crates/audio/tests/audio/malformed.rs +++ b/crates/audio/tests/audio/malformed.rs @@ -324,7 +324,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() } @@ -873,3 +873,63 @@ fn damaged_mp4_and_matroska_are_malformed() { assert!(matches!(craft_audio::tracks(&f, None), Err(AudioError::Malformed(_)))); } } + +/// MP3 in MP4 is decoded by symphonia (PCM, AAC, ALAC, Opus and AC-3 are native), so it reaches +/// the checks in `read/mp4.rs`. +fn mp3_mp4(frames: u32, swap: impl Fn(&[u8; 4], Vec) -> Vec) -> Vec { + mp4(frames, &sample_entry(b".mp3", 0, &[], &[]), swap) +} + +/// An `stts` atom of `n` one-sample runs. +fn one_sample_runs(n: u32) -> Vec { + atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat()) +} + +/// soundcraft #1332: symphonia scans an audio track's `stts` table from its start for every +/// packet, so a table of one-sample runs made decoding quadratic. +#[test] +fn an_audio_timing_table_with_a_run_per_sample_is_bounded() { + let frames = 1 << 14; + let at_bound = mp3_mp4(frames, |k, a| if k == b"stts" { one_sample_runs(frames) } else { a }); + assert!( + !matches!(decode(&at_bound, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), + "a table at the bound is not refused for its size" + ); + let past = mp3_mp4(frames + 1, |k, a| if k == b"stts" { one_sample_runs(frames + 1) } else { a }); + assert!(matches!(decode(&past, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "one run past the bound is refused"); +} + +/// The bound follows the sample entry, as symphonia decodes by it, not the handler. +#[test] +fn the_audio_timing_bound_follows_the_sample_entry_not_the_handler() { + 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 = mp3_mp4(frames, |k, a| match k { + b"stts" => one_sample_runs(frames), + b"hdlr" => video_handler.clone(), + _ => a, + }); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table"))); +} + +/// Symphonia keeps the last `stsd` it reads, so the last one decides. +#[test] +fn the_last_sample_entry_decides_whether_the_timing_bound_applies() { + let video = sample_entry(b"avc1", 0, &[], &[]); + let audio_stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &sample_entry(b".mp3", 0, &[], &[])].concat()); + let frames = (1 << 14) + 1; + let f = mp4(frames, &video, |k, a| if k == b"stts" { [audio_stsd.clone(), one_sample_runs(frames)].concat() } else { a }); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table"))); +} + +/// soundcraft #1332: frames per packet times channels sizes the decoder's output buffer, and the +/// frames bound alone left many channels free to multiply it. +#[test] +fn a_caf_packet_of_far_too_many_samples_is_refused() { + let wide = caf(&[caf_desc(b"ima4", 34, 1 << 20, 18, 16), (b"data", 4, vec![0; 4])]); + let r = decode(&wide, None); + assert!(matches!(&r, Err(AudioError::Malformed(m)) if m.contains("declares packets")), "{:?}", r.map(|x| x.0)); + // the same frames in a stereo stream are within the bound + let stereo = caf(&[caf_desc(b"ima4", 34, 1 << 20, 2, 16), (b"data", 4, vec![0; 4])]); + assert!(!matches!(decode(&stereo, None), Err(AudioError::Malformed(m)) if m.contains("declares packets"))); +}