M11: Export ▸ Images refuses a JBIG2 page larger than it exports, before decoding

The export checked an image's /Width × /Height against its 100-million-
pixel budget, and the JBIG2 page's width against /Width, but not the
page's height. The decoder decodes the whole page, rows past the
image's height too, and takes pages of up to about 2^28 pixels: a
20,000 × 4 image over a 20,000 × 10,000 JBIG2 page cost 200 million
pixels of decoding to export four rows. The page itself is now held to
the budget before it is decoded. Found by CodeRabbit on #4320.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
This commit is contained in:
Grzegorz Martens
2026-10-26 13:53:45 +01:00
co-authored by Claude Opus 5.5
parent 5500eb10f3
commit e76c4f5613
2 changed files with 25 additions and 2 deletions
+6 -1
View File
@@ -126,7 +126,9 @@ fn decode_parms(doc: &Document, s: &Stream) -> Dict {
}
/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller
/// checked against the export's size limit; rows past the image's height are dropped, and too few
/// checked against the export's size limit, and within that limit itself: the decoder decodes the
/// whole page, rows past the image's height too, so a small `/Height` over a far taller page
/// would cost more than the limit allows. Rows past the image's height are dropped, and too few
/// rows are refused by the caller. Globals that can't be read refuse the image, rather than
/// decoding it without them.
fn jbig2(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
@@ -138,6 +140,9 @@ fn jbig2(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
if s.dict.int(b"Width") != Some(i64::from(image.width())) {
return Err("the JBIG2 image's width isn't the image's width".into());
}
if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS {
return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height()));
}
let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0);
let mut rows = Rows::new(image.width() as usize, height);
image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?;
+19 -1
View File
@@ -1392,7 +1392,8 @@ fn images_whose_predictor_is_given_by_reference_export_as_their_picture() {
/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in
/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the
/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image,
/// or data that isn't JBIG2, skips the image.
/// or larger than an exported image may be (however small the image's `/Height`), or data that
/// isn't JBIG2, skips the image.
#[test]
fn jbig2_images_export_as_their_picture() {
let (w, h) = (203i64, 41i64);
@@ -1457,6 +1458,23 @@ fn jbig2_images_export_as_their_picture() {
for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] {
assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len());
}
// The same stream with its page information (the first segment's data, after its 11-byte
// header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the
// export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page
// (it takes pages of up to about 2^28 pixels), so it is refused first.
let mut large = jbig2.clone();
large[11..15].copy_from_slice(&20_000u32.to_be_bytes());
large[15..19].copy_from_slice(&10_000u32.to_be_bytes());
assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size");
// A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused.
for (rows, refused) in [(5_000u32, false), (5_001, true)] {
large[15..19].copy_from_slice(&rows.to_be_bytes());
let doc = page(20_000, 4, &large, None, false);
assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}");
if !refused {
assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4);
}
}
// Damaged data never panics.
for end in [0, 1, 10, 20, 30, jbig2.len() / 2] {
let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0);