Never crash: absurd image-mask and CCITT sizes are refused before decoding

The first full nightly fuzz run on main (now that the runner survives) found
two 4 GiB allocations, caught by the 4 GiB child cap:
- an inline stencil mask claiming /W 4294967295 was decoded in hayro-interpret
  before the renderer's MAX_IMAGE_PIXELS check could skip it (locally 9.6 GB,
  42 s). Vendored patch image_size_ok: images and masks over 2^28 pixels are
  not decoded.
- a CCITT image whose /DecodeParms claimed /Columns 4294967295 (with a small
  /Width) sized the decoder's line buffers (locally 4.3 GB). Vendored patch
  ccitt_size_ok in hayro-syntax: at most 2^20 columns and 2^28 pixels.
Both findings now run in 7 MB. Synthetic regression test in render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 00:10:17 -04:00
co-authored by Claude Opus 5.5
parent 1449b71a19
commit 6058068266
6 changed files with 71 additions and 2 deletions
+41
View File
@@ -780,6 +780,47 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
/// From the nightly `cargo xtask fuzz` (CI caps each child at 4 GiB): a stencil mask claiming
/// /W 4294967295 and a CCITT image claiming /Columns 4294967295 each allocated 4 GiB while
/// decoding (locally: 9.6 GB and 4.3 GB). Vendored hayro patches `image_size_ok` and
/// `ccitt_size_ok` refuse such sizes before decoding; the rest of the page still draws.
#[test]
fn absurd_mask_and_fax_sizes_are_refused_before_decoding() {
assert!(hayro::hayro_interpret::image_size_ok(8000, 8000));
assert!(!hayro::hayro_interpret::image_size_ok(4_294_967_295, 2));
assert!(!hayro::hayro_interpret::image_size_ok(0, 10));
assert!(hayro::hayro_syntax::ccitt_size_ok(1728, 2200), "a fax page");
assert!(!hayro::hayro_syntax::ccitt_size_ok(4_294_967_295, 26));
assert!(!hayro::hayro_syntax::ccitt_size_ok(1 << 19, 1 << 12));
let page = |content: &str, xobject: &str| {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj {xobject} endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
};
let red = "1 0 0 rg 0 0 4 4 re f";
page(&format!("q 20 0 0 20 5 5 cm BI /W 4294967295 /H 2 /IM true /BPC 1 ID \u{0}\u{ff}\u{ff}\u{0} EI Q {red}"), "<< >>");
let fax = "<< /Type /XObject /Subtype /Image /Width 81 /Height 26 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /Columns 4294967295 /Rows 26 /K -1 >> /Length 4 >> stream\n\u{0}\u{0}\u{0}\u{0}\nendstream";
page(&format!("q 20 0 0 20 5 5 cm /Im1 Do Q {red}"), fax);
}
/// From `cargo xtask fuzz`: an inline image claiming /W 4294967295 over four bytes of data
/// hung in resampling (vendored hayro patch: `MAX_IMAGE_PIXELS`).
#[test]
+2 -2
View File
@@ -7,10 +7,10 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields` |
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding` |
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step` |
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows); (2) predictor row widths use saturating arithmetic | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process | `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding` |
Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`.
+2
View File
@@ -41,6 +41,8 @@ mod ocg;
mod soft_mask;
mod types;
mod x_object;
/// PrintCraft patch: exported for its regression test.
pub use x_object::image_size_ok;
pub mod color;
pub mod encode;
+12
View File
@@ -399,6 +399,12 @@ fn decode_context<'a>(
obj: &ImageXObject<'a>,
target_dimension: Option<(u32, u32)>,
) -> Option<DecodeContext<'a>> {
// PrintCraft patch: images and masks over `MAX_IMAGE_PIXELS` are not decoded at all (the
// renderer skips them anyway): a fuzzed `/W 4294967295` stencil mask allocated gigabytes here.
if !image_size_ok(obj.width, obj.height) {
(obj.warning_sink)(InterpreterWarning::ImageDecodeFailure);
return None;
}
let dict = obj.stream.dict();
let dict_bpc = dict
.get::<u8>(BPC)
@@ -968,3 +974,9 @@ fn apply_decode_array(
Some(decoded_arr)
}
/// PrintCraft patch: whether an image or mask of `width` × `height` may be decoded (at most
/// 2^28 pixels, the renderer's `MAX_IMAGE_PIXELS`).
pub fn image_size_ok(width: u32, height: u32) -> bool {
width > 0 && height > 0 && u64::from(width) * u64::from(height) <= 1 << 28
}
+5
View File
@@ -34,6 +34,11 @@ pub(crate) fn decode(
invert_black: params.get::<bool>(BLACK_IS_1).unwrap_or(false),
};
// PrintCraft patch: refuse absurd sizes before the decoder allocates for them.
if !super::ccitt_size_ok(settings.columns, settings.rows) {
return None;
}
// Whenever possible (if we don't have an indexed color space), we convert
// the data as 8-bit instead of 1-bit, so that it can be easier converted
// into an RGBA8 image.
+9
View File
@@ -131,3 +131,12 @@ impl Filter {
res
}
}
/// PrintCraft patch: whether a CCITT image of `columns` × `rows` may be decoded. The decoder sizes
/// its line buffers from `/Columns`, which a fuzzed file set to 4294967295 (a 4 GiB allocation);
/// real fax lines are a few thousand pixels wide.
pub fn ccitt_size_ok(columns: u32, rows: u32) -> bool {
const MAX_COLUMNS: u32 = 1 << 20;
const MAX_PIXELS: u64 = 1 << 28;
columns > 0 && columns <= MAX_COLUMNS && u64::from(columns) * u64::from(rows.max(1)) <= MAX_PIXELS
}