From adcba3a865284801570ecf0454c01fcdb84dc262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 02:17:38 -0300 Subject: [PATCH] Never crash: tiles far larger than the image are refused before decoding (#2554) check_image bounded the image but not its tiles, whose nominal size comes from the file: a 16x16 DNG with one 16384x16384 lossless-JPEG tile (coded 16 rows tall) passed the header-only import, then the full decode resized that tile to 512 MiB (N x 2 GiB with more tiles). check_image now bounds the sum of the chunks' nominal samples to four times the image's (a tile grid no larger than the image covers less than twice each dimension), with a floor of one 2048x2048 tile for tiny images. That covers DNG and both ARW tiled readers, in the header probe and the full decode. The #1512 test keeps covering the uncompressed little-data check with a 2048x2048 tile; a 16384x16384 one is now refused earlier. --- crates/raw/src/tiffraw.rs | 48 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index a781151b4..203d5c773 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -54,6 +54,14 @@ pub fn check_image(data: &[u8], info: &ImageInfo) -> Result { if chunks.len() < expected { return Err(RawError::Corrupt(format!("{} image data chunks, expected {expected}", chunks.len()))); } + // Tile sizes don't depend on the image size, and every tile is decoded at its nominal size and held until the copy: + // a grid of tiles no larger than the image covers less than twice its width and height, so more than four times + // its samples (or one tile of more than TILE_SAMPLES_FLOOR on a tiny image) is no picture (issue #2554). + let ccpp = if info.planar == 2 { 1 } else { cpp as u64 }; + let nominal = chunks.iter().fold(0u64, |sum, c| sum.saturating_add(u64::from(c.width).saturating_mul(u64::from(c.height)).saturating_mul(ccpp))); + if nominal > (total as u64).saturating_mul(4).max(TILE_SAMPLES_FLOOR) { + return Err(RawError::Limit("image data chunks far larger than the image")); + } // Plausibility bound before allocating: no supported coding stores more than ~2000 samples per byte // (Deflate of constant data is the extreme), so tiny files cannot trigger huge allocations. let available: u64 = chunks.iter().map(|c| chunk_bytes(data, c).map_or(0, |s| s.len() as u64)).sum(); @@ -95,6 +103,9 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or } } +/// The nominal samples of all chunks [`check_image`] allows whatever the image size: one 2048 × 2048 tile. +const TILE_SAMPLES_FLOOR: u64 = 1 << 22; + /// Decode all chunks of `info` into one buffer of `width × height × cpp` samples. pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { let (w, h) = (info.width as usize, info.height as usize); @@ -417,8 +428,9 @@ 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. + /// A small image whose uncompressed tile claims to be enormous (2048 x 2048 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. (A + /// tile larger still is refused by `check_image` before any decode: `tiles_far_larger_than_the_image_are_refused`.) #[test] fn a_huge_nominal_uncompressed_tile_with_little_data_is_refused() { let info = |tile: u32, bytes: u64| ImageInfo { @@ -439,7 +451,37 @@ mod lossy_tests { 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(_)))); + assert!(matches!(read_image(&data, &info(2048, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + } + + /// A 16 × 16 image whose lossless-JPEG tile claims to be 16384 × 16384 (coded only as tall as the image, so the + /// file is small) is refused before the tile is allocated, by the header check too (issue #2554); a 256 × 256 + /// tile on the same image, as a writer with a fixed tile size stores it, still decodes. + #[test] + fn tiles_far_larger_than_the_image_are_refused() { + let read = |tile: u32, mode: crate::Mode| { + let t = ljpeg::encode(&vec![300u16; tile as usize * 16], tile as usize, 16, 1, 12, 1, 0); + let info = ImageInfo { + width: 16, + height: 16, + bits_per_sample: vec![12], + samples_per_pixel: 1, + compression: comp::JPEG, + 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![t.len() as u64], + }; + read_image_in(mode, &t, &info, ByteOrder::Little, Packing::Msb) + }; + assert_eq!(read(256, crate::Mode::Full), Ok(RawData::U16(vec![300; 16 * 16]))); + for mode in [crate::Mode::Header, crate::Mode::Full] { + assert!(matches!(read(16384, mode), Err(RawError::Limit(_)))); + } } /// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter.