fix(render): bound xref streams, JBIG2 referred-to counts, saved graphics states and /W2 runs (#4350)

* fix(render): bound xref streams, JBIG2 referred-to counts, saved graphics states and /W2 runs

Input bounds and crash fixes in the vendored hayro crates, ported from
the snapshot craft-libs carries (storytold/craft-libs#176, #239, #241):

- hayro-syntax: an xref stream subsection must be backed by its decoded
  data. All-zero /W widths and a count longer than the data are refused
  before any entry is inserted; a tiny stream with a large /Size or
  /Index count inserted that many entries.
- hayro-syntax: a subsection running past object number u32::MAX is
  refused; start + i overflowed.
- hayro-syntax: the xref stream type field is read big-endian at its
  full /W[0] width, and /W[0] over 8 bytes is refused like /W[1].
- hayro-jbig2: the long-form referred-to segment count is capped at
  u16::MAX before its retention bytes are read; the 29-bit count went
  straight to Vec::with_capacity (about 2 GiB) and aborted the process.
- hayro-interpret: at most 4096 saved graphics states holding at most
  64 MiB together. A q past either limit is counted but not copied, and
  its Q still removes the clips added since.
- hayro-interpret: a /W2 run whose first CID is past MAX_CID stops there,
  as /W does; a first CID of 4294967295 discarded the whole /W2 table.

Each patch is marked in the source, has a regression test in its crate,
and is listed in vendor/README.md.

* fix(render): a binary usecmap counts against the CMap nesting limit; /W2 checks the CID first

- hayro-cmap: a binary CMap's usecmap passed its own depth to the nested
  parse, so a self-referencing binary CMap recursed until the stack
  overflowed. It now counts like a text usecmap (ledger hayro-cmap 2).
- hayro-interpret: the /W2 first-CID check runs before the run's metrics
  are read, so an incomplete run past MAX_CID no longer discards the rest
  of the table (ledger 52).

* fix(render): refuse xref-stream object numbers past i32::MAX

ObjectIdentifier stores an i32; a number between i32::MAX and u32::MAX
was cast to a negative object number (ledger hayro-syntax 26).
This commit is contained in:
aiongg
2026-10-23 22:17:12 -04:00
committed by GitHub
parent a2ec27b477
commit 0c748e34a8
7 changed files with 336 additions and 19 deletions
+4 -4
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -109,10 +109,13 @@ pub(crate) fn parse<'a>(
SEGMENT_USECMAP => { SEGMENT_USECMAP => {
let base_data = get_cmap(CMapName::from_bytes(payload))?; let base_data = get_cmap(CMapName::from_bytes(payload))?;
// PdfCraft patch: a binary `usecmap` counts against the nesting limit as a text
// one does; passing the same depth let a self-referencing CMap recurse until the
// stack overflowed.
base = Some(Box::new(parse::parse_inner( base = Some(Box::new(parse::parse_inner(
base_data, base_data,
get_cmap.clone(), get_cmap.clone(),
depth, depth + 1,
)?)); )?));
} }
SEGMENT_WMODE => { SEGMENT_WMODE => {
+11
View File
@@ -737,6 +737,17 @@ endcodespacerange
CMap::parse(&data, |_| None).unwrap() CMap::parse(&data, |_| None).unwrap()
} }
/// PdfCraft patch test: a binary CMap whose `usecmap` names itself stops at the nesting limit.
#[cfg(feature = "embed-cmaps")]
#[test]
fn self_referencing_binary_usecmap_stops_at_the_nesting_limit() {
// Header (magic, version, file length 19), then one usecmap segment (length 9) naming "Self".
const SELF_REF: &[u8] = &[
b'b', b'c', b'm', b'a', b'p', 1, 0, 0, 0, 19, 0x09, 0, 0, 0, 9, b'S', b'e', b'l', b'f',
];
assert!(CMap::parse(SELF_REF, |_| Some(SELF_REF)).is_none());
}
#[test] #[test]
fn metadata_parsing() { fn metadata_parsing() {
let data = br#" let data = br#"
+151 -2
View File
@@ -169,6 +169,30 @@ pub struct Context<'a> {
pub(crate) xref: &'a XRef, pub(crate) xref: &'a XRef,
pub(crate) ocg_state: OcgState, pub(crate) ocg_state: OcgState,
nesting_depth: u32, nesting_depth: u32,
/// PdfCraft patch: estimated bytes held by saved states, and for each `q` not saved because
/// a limit was reached, the clip count to return to at its `Q`.
saved_bytes: usize,
skipped_saves: Vec<usize>,
}
/// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash
/// array and clip list included, so without a limit short operators multiply one large state.
const MAX_SAVED_STATES: usize = 4096;
/// PdfCraft patch: most bytes all saved states may hold together.
const MAX_SAVED_STATE_BYTES: usize = 64 << 20;
/// Roughly what a saved copy of `state` holds.
fn state_bytes(state: &State<'_>) -> usize {
size_of::<State<'_>>()
.saturating_add(
state
.graphics_state
.stroke_props
.dash_array
.len()
.saturating_mul(size_of::<f32>()),
)
.saturating_add(state.clips.len().saturating_mul(size_of::<ClipType>()))
} }
impl<'a> Context<'a> { impl<'a> Context<'a> {
@@ -223,15 +247,33 @@ impl<'a> Context<'a> {
interpreter_cache: cache.clone(), interpreter_cache: cache.clone(),
ocg_state, ocg_state,
nesting_depth, nesting_depth,
saved_bytes: 0,
skipped_saves: Vec::new(),
} }
} }
pub(crate) fn save_state(&mut self) { pub(crate) fn save_state(&mut self) {
let Some(cur) = self.states.last().cloned() else { let Some(cur) = self.states.last() else {
warn!("attempted to save state without existing state"); warn!("attempted to save state without existing state");
return; return;
}; };
// PdfCraft patch: past either limit the `q` is recorded but nothing is copied. Its `Q`
// still removes the clips added since; other changes made in between are kept.
let bytes = state_bytes(cur);
if !self.skipped_saves.is_empty()
|| self.states.len() >= MAX_SAVED_STATES
|| self.saved_bytes.saturating_add(bytes) > MAX_SAVED_STATE_BYTES
{
if self.skipped_saves.is_empty() {
warn!("too many saved graphics states; further saves are not kept");
}
self.skipped_saves.push(cur.clips.len());
return;
}
let cur = cur.clone();
self.saved_bytes = self.saved_bytes.saturating_add(bytes);
self.states.push(cur); self.states.push(cur);
} }
@@ -308,6 +350,14 @@ impl<'a> Context<'a> {
} }
pub(crate) fn restore_state(&mut self, device: &mut impl Device<'a>) { pub(crate) fn restore_state(&mut self, device: &mut impl Device<'a>) {
// PdfCraft patch: a save that was not kept is the most recent one.
if let Some(target_clips) = self.skipped_saves.pop() {
while self.get().clips.len() > target_clips {
self.pop_clip_path(device);
}
return;
}
let Some(target_clips) = self let Some(target_clips) = self
.states .states
.get(self.states.len().saturating_sub(2)) .get(self.states.len().saturating_sub(2))
@@ -324,6 +374,10 @@ impl<'a> Context<'a> {
// The first state should never be popped. // The first state should never be popped.
if self.states.len() > 1 { if self.states.len() > 1 {
self.states.pop(); self.states.pop();
// PdfCraft patch: the state now on top is the copy that was charged when saved.
if let Some(top) = self.states.last() {
self.saved_bytes = self.saved_bytes.saturating_sub(state_bytes(top));
}
} }
device.set_soft_mask( device.set_soft_mask(
@@ -395,7 +449,8 @@ impl<'a> Context<'a> {
} }
pub(crate) fn num_states(&self) -> usize { pub(crate) fn num_states(&self) -> usize {
self.states.len() // PdfCraft patch: saves not kept still count, so callers restore back past them.
self.states.len().saturating_add(self.skipped_saves.len())
} }
pub(crate) fn nesting_depth(&self) -> u32 { pub(crate) fn nesting_depth(&self) -> u32 {
@@ -517,3 +572,97 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option<Rect> {
Some(path.fast_bounding_box()) Some(path.fast_bounding_box())
} }
/// PdfCraft patch tests (ported from craft-libs): the graphics-state stack is bounded by count
/// and by bytes, and every `q` still pairs with its `Q`.
#[cfg(test)]
mod saved_state_tests {
use super::*;
use crate::device::DummyDevice;
use hayro_syntax::Pdf;
fn pdf() -> Pdf {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Count 0 /Kids [] >>",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(b"xref\n0 3\n0000000000 65535 f \n");
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!("trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
);
Pdf::new(out).expect("synthetic pdf")
}
fn triangle() -> BezPath {
let mut path = BezPath::new();
path.move_to((10.0, 10.0));
path.line_to((90.0, 10.0));
path.line_to((50.0, 90.0));
path.close_path();
path
}
/// Saves `saves` more times, checks a clip added after the last save goes at its `Q`, and unwinds.
fn save_and_unwind(ctx: &mut Context<'_>, saves: usize) {
let before = ctx.num_states();
for _ in 0..saves {
ctx.save_state();
}
assert!(ctx.states.len() <= MAX_SAVED_STATES);
assert!(ctx.saved_bytes <= MAX_SAVED_STATE_BYTES);
assert_eq!(ctx.num_states(), before + saves, "every q still counts");
let mut device = DummyDevice;
let clips = ctx.get().clips.len();
ctx.push_clip_path(triangle(), FillRule::NonZero, &mut device);
assert_eq!(ctx.get().clips.len(), clips + 1);
ctx.restore_state(&mut device);
assert_eq!(ctx.get().clips.len(), clips);
while ctx.num_states() > 1 {
ctx.restore_state(&mut device);
}
assert_eq!(ctx.states.len(), 1);
assert!(ctx.skipped_saves.is_empty());
assert_eq!(ctx.saved_bytes, 0);
}
#[test]
fn repeated_saves_stay_bounded_and_balanced() {
let pdf = pdf();
let cache = InterpreterCache::new();
let bbox = Rect::new(0.0, 0.0, 100.0, 100.0);
let settings = InterpreterSettings::default();
let mut ctx = Context::new(Affine::IDENTITY, bbox, &cache, pdf.xref(), settings);
// A small state reaches the count limit.
save_and_unwind(&mut ctx, 20_000);
// A large one (a 64 Ki-entry dash array, 256 KiB a copy) reaches the byte limit first.
ctx.get_mut().graphics_state.stroke_props.dash_array = (0..1 << 16).map(|_| 2.0).collect();
ctx.save_state();
assert_eq!(ctx.states.len(), 2);
while ctx.skipped_saves.is_empty() {
ctx.save_state();
}
assert!(ctx.states.len() < MAX_SAVED_STATES);
save_and_unwind(&mut ctx, 1000);
// Once unwound, a kept save restores what changed after it.
let mut device = DummyDevice;
ctx.save_state();
ctx.pre_concat_affine(Affine::scale(2.0));
ctx.restore_state(&mut device);
assert_eq!(ctx.get().ctm, Affine::IDENTITY);
}
}
+27
View File
@@ -618,6 +618,12 @@ fn read_widths2(arr: &Array<'_>) -> Option<HashMap<u32, [f32; 3]>> {
let mut iter = range.iter::<f32>(); let mut iter = range.iter::<f32>();
while let Some(w) = iter.next() { while let Some(w) = iter.next() {
// PdfCraft patch: as in `read_widths`, checked before the run's metrics are
// read. Without it, a run starting at 4294967295 overflowed `first` and
// discarded the whole /W2 table.
if first > MAX_CID {
break;
}
let v1 = iter.next()?; let v1 = iter.next()?;
let v2 = iter.next()?; let v2 = iter.next()?;
map.insert(first, [w, v1, v2]); map.insert(first, [w, v1, v2]);
@@ -669,3 +675,24 @@ fn read_encoding(object: &Object<'_>, cmap_resolver: &CMapResolverFn) -> Option<
_ => None, _ => None,
} }
} }
/// PdfCraft patch test (ported from craft-libs): a /W2 run whose first CID is past `MAX_CID`
/// is skipped and the rest of the table kept.
#[cfg(test)]
mod tests {
use super::read_widths2;
use hayro_syntax::object::{Array, FromBytes};
#[test]
fn w2_run_past_the_largest_cid_keeps_the_rest_of_the_table() {
let arr =
Array::from_bytes(b"[4294967295 [1000 500] 10 [900 450 860] 20 30 800 400 870]")
.unwrap();
let map = read_widths2(&arr).unwrap();
assert_eq!(map.get(&4_294_967_295), None);
assert_eq!(map.get(&10), Some(&[900.0, 450.0, 860.0]));
assert_eq!(map.get(&25), Some(&[800.0, 400.0, 870.0]));
assert_eq!(map.len(), 12);
}
}
+42
View File
@@ -8,6 +8,12 @@ use alloc::vec::Vec;
use crate::error::{ParseError, Result, SegmentError, bail, err}; use crate::error::{ParseError, Result, SegmentError, bail, err};
use crate::reader::Reader; use crate::reader::Reader;
/// PdfCraft patch: the most segments one segment may refer to. A long-form header can claim a
/// 29-bit count, which `Vec::with_capacity` below would turn into a multi-gigabyte reservation
/// before a single referred-to number is read; the allocation failure aborts the process. Real
/// files refer to a handful of segments.
pub(crate) const MAX_REFERRED_TO_SEGMENTS: u32 = u16::MAX as u32;
/// "The segment type is a number between 0 and 63, inclusive. Not all values /// "The segment type is a number between 0 and 63, inclusive. Not all values
/// are allowed." (7.3) /// are allowed." (7.3)
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -183,6 +189,12 @@ pub(crate) fn parse_segment_header(reader: &mut Reader<'_>) -> Result<SegmentHea
u32::from_be_bytes([count_and_retention & 0x1F, rest[0], rest[1], rest[2]]) u32::from_be_bytes([count_and_retention & 0x1F, rest[0], rest[1], rest[2]])
}; };
// PdfCraft patch: refuse an oversized count before reserving memory for it and before
// skipping the retention-flag bytes it claims.
if referred_to_count > MAX_REFERRED_TO_SEGMENTS {
bail!(SegmentError::InvalidReferredCount);
}
// Skip retention flag bytes in long form. // Skip retention flag bytes in long form.
// "The first one-byte field following the initial four-byte field is formatted // "The first one-byte field following the initial four-byte field is formatted
// as follows: Bit 0: Retain bit for this segment. Bit 1-7: Retain bits for // as follows: Bit 0: Retain bit for this segment. Bit 1-7: Retain bits for
@@ -416,4 +428,34 @@ mod tests {
assert_eq!(header.data_length, Some(32)); assert_eq!(header.data_length, Some(32));
} }
/// PdfCraft patch test (ported from craft-libs): a long-form referred-to count over
/// `MAX_REFERRED_TO_SEGMENTS` is refused before its retention bytes are read.
#[test]
fn test_referred_to_count_is_capped() {
use crate::error::DecodeError;
let header = |count: u32| {
let mut data = vec![
0x00, 0x01, 0x00, 0x00, // Segment number = 65536
0x86, // Flags: type 6, page assoc 1 byte, deferred non-retain
];
// Long form: bits 29-31 are 7, bits 0-28 the count.
data.extend_from_slice(&(0xE000_0000 | count).to_be_bytes());
parse_segment_header(&mut Reader::new(&data)).unwrap_err()
};
// The 29-bit maximum, 536,870,911, would reserve about 2 GiB.
for count in [0x1FFF_FFFF, MAX_REFERRED_TO_SEGMENTS + 1] {
assert!(matches!(
header(count),
DecodeError::Segment(SegmentError::InvalidReferredCount)
));
}
// At the cap the header is read on and ends at its missing retention bytes.
assert!(matches!(
header(MAX_REFERRED_TO_SEGMENTS),
DecodeError::Parse(ParseError::UnexpectedEof)
));
}
} }
+97 -12
View File
@@ -965,17 +965,13 @@ fn populate_from_xref_stream<'a>(
let [f1_len, f2_len, f3_len] = stream.dict().get::<[u8; 3]>(W)?; let [f1_len, f2_len, f3_len] = stream.dict().get::<[u8; 3]>(W)?;
if f2_len > size_of::<u64>() as u8 { // PdfCraft patch: the type field is read at its full width, so it is bounded like the offset.
error!("xref offset length is larger than the allowed limit"); if f1_len > size_of::<u64>() as u8 || f2_len > size_of::<u64>() as u8 {
error!("xref field length is larger than the allowed limit");
return None; return None;
} }
// Do such files exist?
if f1_len != 1 {
warn!("first field in xref stream was longer than 1");
}
let xref_data = stream.decoded().ok()?; let xref_data = stream.decoded().ok()?;
// PdfCraft patch: (#2366) this section's entries are read first, for `prev_offset`. // PdfCraft patch: (#2366) this section's entries are read first, for `prev_offset`.
let mut own = XrefMap::default(); let mut own = XrefMap::default();
@@ -1042,15 +1038,38 @@ fn xref_stream_subsection<'a>(
f3_len: u8, f3_len: u8,
insert_map: &mut XrefMap, insert_map: &mut XrefMap,
) -> Option<()> { ) -> Option<()> {
// PdfCraft patch: every entry must be backed by stream bytes. With all widths zero an entry
// reads nothing, so a tiny stream could declare billions of entries; and a count past the
// decoded data is refused before any of it is inserted.
let width = f1_len as usize + f2_len as usize + f3_len as usize;
if width == 0 {
warn!("xref stream entries have no width");
return None;
}
let available = xref_reader.len().saturating_sub(xref_reader.offset());
if (num_elements as usize)
.checked_mul(width)
.is_none_or(|need| need > available)
{
warn!("xref stream subsection declares more entries than its data holds");
return None;
}
for i in 0..num_elements { for i in 0..num_elements {
// A zero-width type field defaults to 1 (ISO 32000-2 §7.5.8.2, `/W`).
let f_type = if f1_len == 0 { let f_type = if f1_len == 0 {
1 1
} else { } else {
// We assume a length of 1. // PdfCraft patch: the type field is read big-endian at its full `/W[0]` width.
xref_reader.read_bytes(1)?[0] let bytes = xref_reader.read_bytes(f1_len as usize)?;
bytes.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b))
}; };
let obj_number = start + i; // PdfCraft patch: object numbers are stored as `i32`; a subsection running past
// `i32::MAX` is refused instead of overflowing or turning negative.
let obj_number = i32::try_from(start.checked_add(i)?).ok()?;
match f_type { match f_type {
// We don't care about free objects. // We don't care about free objects.
@@ -1073,7 +1092,7 @@ fn xref_stream_subsection<'a>(
}; };
insert_map.insert( insert_map.insert(
ObjectIdentifier::new(obj_number as i32, gen_number as i32), ObjectIdentifier::new(obj_number, gen_number as i32),
EntryType::Normal(offset as usize), EntryType::Normal(offset as usize),
); );
} }
@@ -1091,7 +1110,7 @@ fn xref_stream_subsection<'a>(
}; };
insert_map.insert( insert_map.insert(
ObjectIdentifier::new(obj_number as i32, gen_number), ObjectIdentifier::new(obj_number, gen_number),
EntryType::ObjStream(obj_stream_number, index), EntryType::ObjStream(obj_stream_number, index),
); );
} }
@@ -1248,6 +1267,72 @@ fn parse_metadata(info_dict: &Dict<'_>) -> Metadata {
mod tests { mod tests {
use super::*; use super::*;
// PdfCraft patch tests (ported from craft-libs): xref stream entries are bounded by the
// decoded data, and the type field is read at its full width.
#[test]
fn xref_stream_entries_need_backing_bytes() {
let mut map = XrefMap::default();
// All widths zero: an entry reads nothing, so none may be declared.
let mut empty = Reader::new(&[]);
assert!(xref_stream_subsection(&mut empty, 0, u32::MAX, 0, 0, 0, &mut map).is_none());
assert!(map.is_empty());
// Three entries of width 3 need nine bytes; eight are refused before any insert.
let data = [1, 0, 9, 1, 0, 9, 1, 0];
let mut short = Reader::new(&data);
assert!(xref_stream_subsection(&mut short, 0, 3, 1, 1, 1, &mut map).is_none());
assert!(map.is_empty());
let mut whole = Reader::new(&data[..6]);
assert!(xref_stream_subsection(&mut whole, 0, 2, 1, 1, 1, &mut map).is_some());
assert_eq!(map.len(), 2);
// A subsection running past object number i32::MAX is refused.
for start in [i32::MAX as u32, u32::MAX] {
let mut past_end = XrefMap::default();
let mut wrap = Reader::new(&data[..6]);
assert!(xref_stream_subsection(&mut wrap, start, 2, 1, 1, 1, &mut past_end).is_none());
assert!(past_end.keys().all(|id| id.obj_number >= 0));
}
}
#[test]
fn xref_stream_type_field_uses_the_full_w0_width() {
// /W [2 1 1]: two-byte type 0x0001 (normal), offset 7, gen 0.
let mut map = XrefMap::default();
let mut reader = Reader::new(&[0u8, 1, 7, 0]);
assert!(xref_stream_subsection(&mut reader, 5, 1, 2, 1, 1, &mut map).is_some());
assert!(matches!(
map.get(&ObjectIdentifier::new(5, 0)),
Some(EntryType::Normal(7))
));
}
#[test]
fn xref_stream_loader_rejects_oversized_w0() {
// One normal entry (offset 7, gen 0) whose type field is `type_len` bytes wide, so only
// the width of /W[0] differs between the two streams.
let load = |type_len: usize| {
let mut entry = vec![0u8; type_len];
entry[type_len - 1] = 1;
entry.extend_from_slice(&[7, 0]);
let mut data = format!(
"1 0 obj\n<< /Type /XRef /Size 6 /Index [5 1] /W [{type_len} 1 1] /Length {} >>\nstream\n",
entry.len()
)
.into_bytes();
data.extend_from_slice(&entry);
data.extend_from_slice(b"\nendstream\nendobj\n");
let mut map = XrefMap::default();
let got = populate_from_xref_stream(
&data,
&mut Reader::new(&data),
&mut map,
&mut BTreeSet::new(),
);
got.is_some()
};
assert!(load(8));
assert!(!load(9));
}
// Contributor-original, MIT OR Apache-2.0. Matches the bounded, uncompressed // Contributor-original, MIT OR Apache-2.0. Matches the bounded, uncompressed
// synthetic regression used to reproduce the cold-publication race. A second // synthetic regression used to reproduce the cold-publication race. A second
// stream optionally checks that racing registration never aliases two ids. // stream optionally checks that racing registration never aliases two ids.