Browse / github.com/storytold/soundcraft / patch
Audio import: regression test for oversized Matroska element sizes (fixes #878)
[Audit] Matroska/EBML element size triggers a 128 TiB allocation and aborts the process Found in a security and robustness audit of `main` at 9b8283b (0.11.0). **Code:** [`crates/audio-io/src/symph.rs:56-67`](https://github.com/storytold/soundcraft/blob/9b8283b90877813a929f5e28b2dc92e5774da933/crates/audio-io/src/symph.rs#L56-L67) - **Severity:** High. **Confidence:** **Confirmed, reproduced at 9b8283b.** A fix was proposed in #746 (closed unmerged). The only `symph.rs` change upstream, 8840fbc, affects the `fLaC` arm. **Evidence:** - `crates/audio-io/src/symph.rs:56-67` runs pre-checks only for these formats: ```rust Some(b"caff") => crate::caf::check(bytes, at)?
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ayozetr (imported from pull request #1149, open)
- Base
a8c19f0ffe44b992aad227bc409e5de01ceb59c4- Size
- +64 −0 in 1 file
- Review
- pending
- Shared
- 2026-10-28
- Id
fc14fd2b6dc4a468d28e5f45
How it went
No other operator has reported using it yet.
Diff
raw · crates/audio-io/tests/it/malformed.rs
@@ -208,6 +208,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() { assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets"))); } +/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and+/// `u64::MAX` writes the reserved "unknown size"), then `body`.+fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {+ let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };+ [id, &size.to_be_bytes(), body].concat()+}++/// The EBML header of a Matroska file.+fn ebml_header() -> Vec<u8> {+ let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");+ ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)+}++/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size+/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch+/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way+/// for the header, for codec data and for block frames.+#[test]+fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {+ let huge = 1u64 << 47;+ // The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.+ let reported = [+ 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,+ 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,+ ];+ let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();+ let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();+ let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);+ // A valid 16-bit PCM track entry's children, followed by `extra`.+ let pcm = |extra: &[u8]| {+ let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();+ [+ ebml(&[0xD7], 1, &[1]),+ ebml(&[0x73, 0xC5], 1, &[1]),+ ebml(&[0x83], 1, &[2]),+ ebml(&[0x86], 13, b"A_PCM/INT/LIT"),+ ebml(&[0xE1], audio.len() as u64, &audio),+ extra.to_vec(),+ ]+ .concat()+ };+ // That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the+ // segment is valid up to that read, which runs out of file.+ let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));+ let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());+ // The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.+ let track = pcm(&[]);+ let track = ebml(&[0xAE], track.len() as u64, &track);+ let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());+ let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());+ let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));+ for hint in [None, Some("mkv"), Some("wav")] {+ assert!(probe(&reported, hint).is_err(), "{hint:?}");+ assert!(decode(&reported, hint).is_err(), "{hint:?}");+ // Refused at the oversized read itself, not by anything before it.+ assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());+ assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");+ // The file and its track open, so decoding reaches the block; the block reader stops at the+ // end of the file, a best-effort read of what is there.+ assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());+ assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");+ }+}+ #[test] fn well_formed_caf_still_reads() { let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();