From 6d080de4470221e2289203c86d51023a9d0c7790 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Wed, 14 Oct 2026 12:47:21 -0500 Subject: [PATCH] Never crash: Check raw data size before allocating in PEF, TIFF tiles and ARW6 (#1512) A compressed PEF too short for its size, an uncompressed tile claiming far more samples than it holds, and an ARW6 with a failed tile now get refused before the big buffer is allocated. --- crates/raw/src/tiffraw.rs | 35 ++++++++++++++++++++++++++++++++++- crates/raw/src/vendor/arw6.rs | 4 ++-- crates/raw/src/vendor/pef.rs | 20 +++++++++++++++++++- docs/whats-new.md | 6 ++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 293fafb61..8093cb9d3 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -156,7 +156,15 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack return Err(RawError::Limit("chunk too large")); } match info.compression { - comp::NONE => unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1), + comp::NONE => { + // uncompressed: missing rows are filled with zeros, but a chunk holding a small fraction of what its + // samples need is no picture: refused before the nominal chunk is allocated (issue #1512) + let need = (n as u64).saturating_mul(u64::from(bits)) / 8; + if (src.len() as u64).saturating_mul(64) < need { + return Err(RawError::Corrupt(format!("an uncompressed chunk of {n} samples has only {} bytes", src.len()))); + } + unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1) + } comp::JPEG => { let f = ljpeg::decode(src, n.saturating_mul(2).max(1 << 16))?; if f.data.len() < n { @@ -405,6 +413,31 @@ mod lossy_tests { assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err()); } + /// A small image whose uncompressed tile claims to be enormous (16384 x 16384 nominal, a few bytes of data) is + /// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes. + #[test] + fn a_huge_nominal_uncompressed_tile_with_little_data_is_refused() { + let info = |tile: u32, bytes: u64| ImageInfo { + width: 8, + height: 8, + bits_per_sample: vec![16], + samples_per_pixel: 1, + compression: comp::NONE, + photometric: 32803, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: tile, tile_height: tile }, + offsets: vec![0], + byte_counts: vec![bytes], + }; + let small = vec![1u8; 2 * 16 * 16]; + assert!(read_image(&small, &info(16, small.len() as u64), ByteOrder::Little, Packing::Msb).is_ok()); + let data = vec![1u8; 4096]; + assert!(matches!(read_image(&data, &info(16384, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + } + /// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter. #[test] fn short_lossless_jpeg_tiles_are_errors() { diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index f6f535bd6..46af909dd 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -524,10 +524,10 @@ pub(crate) fn decode(strip: &[u8], width: usize, height: usize, mode: crate::Mod } return Ok(Vec::new()); } - let decoded: Vec>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect(); + // a tile that fails is reported before the mosaic is allocated (issue #1512) + let decoded: Vec> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect::>()?; let mut out = vec![0u16; width.checked_mul(height).ok_or_else(|| corrupt("image size"))?]; for (t, d) in tiles.iter().zip(decoded) { - let d = d?; for (r, row) in d.chunks_exact(t.width.max(1)).enumerate() { let start = (t.y + r).checked_mul(width).and_then(|a| a.checked_add(t.x)).ok_or_else(|| corrupt("tile placement"))?; let dst = start.checked_add(row.len()).and_then(|end| out.get_mut(start..end)).ok_or_else(|| corrupt("tile outside image"))?; diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 86db33540..49486be74 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -143,7 +143,13 @@ pub(crate) fn table(b: &[u8], order: ByteOrder) -> Result { /// Decode a `w × h` Huffman-coded image. pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits_per: u32) -> Result> { - let mut out = vec![0u16; w * hgt]; + // every sample takes at least one bit: a stream too short for the declared size is refused before its picture + // is allocated, so a few bytes can't ask for gigabytes (issue #1512) + let samples = w.checked_mul(hgt).ok_or(RawError::Limit("image too large"))?; + if (src.len() as u64).saturating_mul(8) < samples as u64 { + return Err(RawError::Corrupt(format!("PEF: {w} x {hgt} samples need more than the {} bytes of data there", src.len()))); + } + let mut out = vec![0u16; samples]; let mut bits = Bits::new(src); let mut vpred = [[0i32; 2]; 2]; let max = (1i32 << bits_per.min(16)) - 1; @@ -358,6 +364,18 @@ mod tests { (b, codes) } + /// A compressed PEF declaring far more samples than its data could hold (one bit each at least) is refused + /// before its picture is allocated (issue #1512). + #[test] + fn a_stream_too_short_for_its_size_is_refused_before_allocating() { + let (tb, _) = test_table(); + let h = table(&tb, ByteOrder::Big).unwrap(); + let t = std::time::Instant::now(); + let r = decode_huffman(&[0x55; 4096], &h, 30_000, 30_000, 12); + assert!(matches!(r, Err(RawError::Corrupt(ref m)) if m.contains("need more")), "{r:?}"); + assert!(t.elapsed() < std::time::Duration::from_secs(1)); + } + fn encode(px: &[u16], w: usize, codes: &[(u16, u8)]) -> Vec { let mut bits: Vec = Vec::new(); let put = |v: u32, n: u8, bits: &mut Vec| (0..n).rev().for_each(|i| bits.push(v >> i & 1 == 1)); diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..8cc534688 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### Reliability +- A damaged or crafted raw can no longer make LightCraft set aside gigabytes of memory before finding out the file is + bad (issue #1512): a compressed PEF whose data is too short for the size it declares, an uncompressed TIFF-based + raw whose tile claims far more samples than it holds, and an ARW6 with a broken tile are refused first. All 74 + files in the test corpus still decode. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.