From 8a4d6f2ebf104f9b68ce9b987dc7ec90f47ce63a Mon Sep 17 00:00:00 2001 From: Adrian D'souza <102761952+bogusyogi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:30:46 +0530 Subject: [PATCH] HEIF: hold uncropped SPS sizes and a grid's tiles together to max_pixels (#1518) Co-Authored-By: Claude Opus 5.5 --- crates/heif/src/lib.rs | 22 ++++++++++++++ crates/heif/src/tests_synthetic.rs | 11 +++++++ crates/heif/src/vui.rs | 49 ++++++++++++++++++++++-------- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 8292024b1..21b9cf647 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) }) } +/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles +/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1. +const GRID_BUDGET: u64 = 2; + /// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. struct Coded<'a> { frames: Vec, @@ -297,6 +301,24 @@ impl<'a> Coded<'a> { let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?; let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? }; let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format); + // #1518: the decoder allocates each picture at its SPS's uncropped size, which a + // conformance window (or a slice picking another SPS) can make far larger than the + // declared one, and a grid holds all its tiles at once. Both are refused before decoding. + let mut total = 0u64; + for t in &ids { + let tp = ctx.props(*t).map_err(err)?; + let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; + let sizes = vui::coded_sizes(&hvcc.parameter_sets()).ok_or_else(|| Error::Malformed("unreadable sequence parameter set".into()))?; + let mut largest = 0u64; + for (w, h) in sizes { + heic_rs::image::check_pixels(w, h, limit).map_err(err)?; + largest = largest.max(u64::from(w) * u64::from(h)); + } + total = total.saturating_add(largest); + } + if total > limit.saturating_mul(GRID_BUDGET) { + return Err(Error::Limit(format!("the coded pictures hold {total} pixels, over the {limit}-pixel limit"))); + } let decode_one = |t: &u32| -> Result { let tp = ctx.props(*t).map_err(err)?; let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs index e229af926..3259dc96d 100644 --- a/crates/heif/src/tests_synthetic.rs +++ b/crates/heif/src/tests_synthetic.rs @@ -146,6 +146,17 @@ fn limits_are_checked_before_decoding() { assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); } +/// #1518: a grid's tiles together are held to the pixel budget before any is decoded. A +/// 33 × 33 canvas fits 1089 pixels, but its four 32 × 32 tiles hold 4096: over twice the limit. +#[test] +fn grid_tiles_are_held_to_the_pixel_budget_together() { + let bytes = build(&Spec::new(33, 33, &grey)); + let r = decode(&bytes, &Options { max_pixels: 33 * 33, ..Default::default() }); + assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); + let d = decode(&bytes, &Options { max_pixels: 2 * 32 * 32, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (33, 33)); +} + #[test] fn truncated_and_corrupted_files_are_errors_not_panics() { let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) }); diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index d732f2863..226604303 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -33,6 +33,41 @@ pub fn signal(parameter_sets: &[&[u8]]) -> Option { parse_sps(&unescape(sps.get(2..)?)) } +/// The uncropped picture size (`pic_width/height_in_luma_samples`) of every SPS among +/// `parameter_sets`: what the decoder allocates before it applies the conformance window, so the +/// pixel budget is checked against it (heic-rs reports only the first SPS, cropped). `None`: an +/// SPS this reader can't follow. +pub fn coded_sizes(parameter_sets: &[&[u8]]) -> Option> { + parameter_sets + .iter() + .filter(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT)) + .map(|sps| { + let rbsp = unescape(sps.get(2..)?); + let r = &mut Bits { data: &rbsp, pos: 0 }; + sps_size(r).map(|(w, h, _)| (w, h)) + }) + .collect() +} + +/// Reads an SPS up to and including the picture size, leaving `r` after it: width, height and +/// `sps_max_sub_layers_minus1`. +fn sps_size(r: &mut Bits) -> Option<(u32, u32, u32)> { + r.skip(4)?; // sps_video_parameter_set_id + let max_sub_layers_minus1 = r.u(3)?; + if max_sub_layers_minus1 > 6 { + return None; + } + r.skip(1)?; // sps_temporal_id_nesting_flag + profile_tier_level(r, max_sub_layers_minus1)?; + r.ue()?; // sps_seq_parameter_set_id + if r.ue()? == 3 { + r.skip(1)?; // separate_colour_plane_flag + } + let width = r.ue()?; // pic_width_in_luma_samples + let height = r.ue()?; // pic_height_in_luma_samples + Some((width, height, max_sub_layers_minus1)) +} + /// Removes emulation prevention bytes (`00 00 03` → `00 00`). fn unescape(nal: &[u8]) -> Vec { let mut out = Vec::with_capacity(nal.len()); @@ -165,19 +200,7 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option Option { let r = &mut Bits { data: rbsp, pos: 0 }; - r.skip(4)?; // sps_video_parameter_set_id - let max_sub_layers_minus1 = r.u(3)?; - if max_sub_layers_minus1 > 6 { - return None; - } - r.skip(1)?; // sps_temporal_id_nesting_flag - profile_tier_level(r, max_sub_layers_minus1)?; - r.ue()?; // sps_seq_parameter_set_id - if r.ue()? == 3 { - r.skip(1)?; // separate_colour_plane_flag - } - r.ue()?; // pic_width_in_luma_samples - r.ue()?; // pic_height_in_luma_samples + let (_, _, max_sub_layers_minus1) = sps_size(r)?; if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset