From 240829203379ab563bdfad6dc10f692d31ca9b40 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 13:30:58 -0500 Subject: [PATCH] craft-heif: cap zero-width iloc extents, apply the census on the thumbnail path (#813) --- crates/heif/src/census.rs | 86 +++++++++++++++++++++++++++++++++++++++ crates/heif/src/lib.rs | 5 ++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/heif/src/census.rs b/crates/heif/src/census.rs index 6dbe035fc..19cc779e2 100644 --- a/crates/heif/src/census.rs +++ b/crates/heif/src/census.rs @@ -25,6 +25,12 @@ const MAX_IPMA_BYTES: usize = 64 << 10; /// construction-method-2 reconstruction chain can visit. const MAX_ILOC_BYTES: usize = 64 << 10; +/// The most extents accepted from an `iloc` whose offset, length and index fields are all +/// zero-width (same as the item-visit budget). Such an extent takes no bytes, so the payload cap +/// does not bound it, yet heic-rs 0.1.4 skips its "more extents than it holds" check and +/// allocates 24 bytes per extent (up to 65535 per item). +const MAX_ZERO_WIDTH_EXTENTS: u64 = 4096; + /// Falls through unless the container declares more `ipma` or `iloc` payload than a /// legitimate phone photo ever does. pub fn cap(bytes: &[u8]) -> Result<(), Error> { @@ -36,6 +42,12 @@ pub fn cap(bytes: &[u8]) -> Result<(), Error> { if c.iloc > MAX_ILOC_BYTES { return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc))); } + if c.zero_width_extents > MAX_ZERO_WIDTH_EXTENTS { + return Err(Error::Limit(format!( + "the image declares {} extents that occupy no bytes, over the {MAX_ZERO_WIDTH_EXTENTS}-extent cap", + c.zero_width_extents + ))); + } Ok(()) } @@ -44,6 +56,30 @@ pub fn cap(bytes: &[u8]) -> Result<(), Error> { struct Census { ipma: usize, iloc: usize, + zero_width_extents: u64, +} + +/// The number of extents an `iloc` payload declares when its extents are zero-width, else 0. +/// Stops silently at a truncated or unknown layout: heic-rs reports those itself. +fn zero_width_extents(p: &[u8]) -> u64 { + let (Some(&version), Some(&sizes), Some(&sizes2)) = (p.first(), p.get(4), p.get(5)) else { return 0 }; + let base_size = usize::from(sizes2 >> 4); + let index_size = if version >= 1 { sizes2 & 0x0f } else { 0 }; + if version > 2 || (sizes >> 4) + (sizes & 0x0f) + index_size != 0 { + return 0; + } + let id_size = if version < 2 { 2 } else { 4 }; + let Some(count) = p.get(6..6 + id_size) else { return 0 }; + let count = count.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)); + let (mut at, mut total) = (6 + id_size, 0u64); + for _ in 0..count { + // id, [construction method], data reference index, base offset, then the extent count + at += id_size + if version >= 1 { 2 } else { 0 } + 2 + base_size; + let Some(n) = p.get(at..at + 2) else { break }; + total += u64::from(u16::from_be_bytes([n[0], n[1]])); + at += 2; + } + total } /// Walks boxes, adding the payload sizes of those named in [`Census`]. @@ -61,6 +97,7 @@ fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) { c.ipma = c.ipma.saturating_add(b.payload.len()); } else if b.is(b"iloc") { c.iloc = c.iloc.saturating_add(b.payload.len()); + c.zero_width_extents = c.zero_width_extents.saturating_add(zero_width_extents(b.payload)); } else if b.is(b"meta") { // `meta` is a FullBox: version and flags sit between its header and its children. if b.payload.len() >= 4 { @@ -111,6 +148,55 @@ mod tests { assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}"); } + /// An `iloc` v0 whose offset, length and base-offset sizes are 0, listing `items` items of + /// `extents` extents each. Each item is 8 bytes, so 4096+ items stay far under the byte cap. + fn zero_width_iloc(items: u16, extents: u16) -> Vec { + let mut iloc = vec![0u8, 0, 0, 0, 0x00, 0x00]; + iloc.extend_from_slice(&items.to_be_bytes()); + for id in 0..items { + iloc.extend_from_slice(&id.to_be_bytes()); + iloc.extend_from_slice(&[0, 0]); // data reference index + iloc.extend_from_slice(&extents.to_be_bytes()); + } + iloc + } + + fn file_with_iloc(iloc: &[u8]) -> Vec { + let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec(); + let mut meta = vec![0u8, 0, 0, 0]; + meta.extend(box_of(b"iloc", iloc)); + out.extend(box_of(b"meta", &meta)); + out + } + + #[test] + fn zero_width_extents_are_counted_not_allocated() { + // 8000 items x 65535 extents would be ~12 GB in heic-rs 0.1.4; the census only counts. + let iloc = zero_width_iloc(8000, u16::MAX); + assert!(iloc.len() < MAX_ILOC_BYTES); + let r = cap(&file_with_iloc(&iloc)); + assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("occupy no bytes")), "{r:?}"); + // at the cap it passes; one more is refused + assert_eq!(zero_width_extents(&zero_width_iloc(4096, 1)), 4096); + assert!(cap(&file_with_iloc(&zero_width_iloc(4096, 1))).is_ok()); + assert!(cap(&file_with_iloc(&zero_width_iloc(4097, 1))).is_err()); + } + + #[test] + fn the_thumbnail_path_applies_the_caps_too() { + let bytes = file_with_iloc(&zero_width_iloc(8000, u16::MAX)); + let r = crate::decode_thumbnail(&bytes, 1, &crate::Options::default()).map(|_| ()); + assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("occupy no bytes")), "{r:?}"); + } + + #[test] + fn extents_with_width_are_left_to_the_byte_cap() { + // offset_size 4, length_size 4: the payload cap already bounds them. + let mut iloc = zero_width_iloc(1, u16::MAX); + iloc[4] = 0x44; + assert_eq!(zero_width_extents(&iloc), 0); + } + #[test] fn a_undersized_ipma_is_untouched_by_the_caps() { // The same shape with a normal ipma (two entries): the caps pass, and whatever error diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 03fbe5174..5d3738373 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -259,6 +259,7 @@ pub fn decode(bytes: &[u8], options: &Options) -> Result { /// profile) are the primary image's. pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Result, Error> { guarded(|| { + census::cap(bytes)?; let ctx = Context::open(bytes).map_err(err)?; let primary = ctx.meta.primary; let mut best: Option<(u32, u32)> = None; @@ -287,7 +288,9 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul } } let mut decoded = decode_item(&ctx, id, options, false)?; - if decoded.icc.is_none() { + // `item_icc` rebuilds the primary grid's descriptor: only when that is within the visit + // budget (the thumbnail path never counted it). + if decoded.icc.is_none() && item_visits(&ctx, primary, 0, &mut 0).is_ok() { decoded.icc = item_icc(&ctx, primary); } (decoded.exif, decoded.xmp) = metadata(&ctx);