mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:47:22 -06:00
Fuzzed inputs drove one check-one process to 2-6 GB (the 16 GB CI runner running two of them was killed every night). Three causes, each with a test: - lopdf (bootstrap inspector) sized PNG predictor rows from /Columns before reading data: /Columns 4294967295 allocated two 4 GiB rows. Vendored lopdf 0.45.0 refuses rows longer than the data (and uses saturating row widths); its load-time stream decoding also gets a 256 MiB limit (it had none). - hayro sized a tiling pattern's cell pixmap from /XStep x scale, bounded only by u16: a step far beyond /BBox allocated 2 GB. The vendored patch keeps the cell within 3000 px a side, lowering the resolution instead. - cos decoded cross-reference streams up to the general 1 GiB limit; they now stop at what 8,388,607 objects' rows can hold (~200 MB at most), and the section is reconstructed (logged) instead. On the nightly's seed (41) the worst captured input fell from 6.2 GB to 6 MB, and a 300 s fuzz run had no process above 300 MB (before: many at 2-5 GB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
8.8 KiB
Rust
232 lines
8.8 KiB
Rust
//! A cross-reference stream declares how many entries it holds in its `/Index`
|
|
//! array, but the entries themselves live in the (decoded) stream body. If the
|
|
//! declared count is allowed to run past what the body can hold, `load` spins on
|
|
//! an attacker-controlled number instead of the bytes actually present.
|
|
|
|
#![cfg(not(feature = "async"))]
|
|
|
|
use std::io::Write;
|
|
|
|
use flate2::{Compression, write::ZlibEncoder};
|
|
use lopdf::{Document, Error, LoadOptions, ParseError, SaveOptions, Stream, dictionary};
|
|
|
|
/// A PDF whose only cross-reference is an xref stream with the given `/W` widths,
|
|
/// `/Index [0 count]`, and `body` as an uncompressed stream body.
|
|
fn xref_stream_pdf(w: [i64; 3], count: i64, body: &[u8]) -> Vec<u8> {
|
|
xref_stream_pdf_with_index(w, &format!("0 {count}"), body)
|
|
}
|
|
|
|
fn xref_stream_pdf_with_index(w: [i64; 3], index: &str, body: &[u8]) -> Vec<u8> {
|
|
let mut pdf = Vec::new();
|
|
pdf.extend_from_slice(b"%PDF-1.5\n");
|
|
let obj_offset = pdf.len();
|
|
pdf.extend_from_slice(b"1 0 obj\n");
|
|
pdf.extend_from_slice(
|
|
format!(
|
|
"<< /Type /XRef /Size 4 /W [{} {} {}] /Index [{index}] /Root 1 0 R /Length {} >>\n",
|
|
w[0],
|
|
w[1],
|
|
w[2],
|
|
body.len()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
pdf.extend_from_slice(b"stream\n");
|
|
pdf.extend_from_slice(body);
|
|
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
|
pdf.extend_from_slice(format!("startxref\n{obj_offset}\n%%EOF").as_bytes());
|
|
pdf
|
|
}
|
|
|
|
fn compressed_xref_stream_pdf(entries: usize) -> Vec<u8> {
|
|
let mut pdf = b"%PDF-1.5\n".to_vec();
|
|
let catalog_offset = pdf.len();
|
|
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
|
|
let xref_offset = pdf.len();
|
|
|
|
let mut body = Vec::with_capacity(entries * 5);
|
|
for id in 0..entries {
|
|
let (entry_type, offset): (u8, u32) = match id {
|
|
1 => (1, catalog_offset as u32),
|
|
2 => (1, xref_offset as u32),
|
|
_ => (0, 0),
|
|
};
|
|
body.push(entry_type);
|
|
body.extend_from_slice(&offset.to_be_bytes()[1..4]);
|
|
body.push(0);
|
|
}
|
|
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
|
|
encoder.write_all(&body).unwrap();
|
|
let compressed_body = encoder.finish().unwrap();
|
|
|
|
pdf.extend_from_slice(b"2 0 obj\n");
|
|
pdf.extend_from_slice(
|
|
format!(
|
|
"<< /Type /XRef /Size {entries} /W [1 3 1] /Index [0 {entries}] /Root 1 0 R \
|
|
/Filter /FlateDecode /Length {} >>\n",
|
|
compressed_body.len()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
pdf.extend_from_slice(b"stream\n");
|
|
pdf.extend_from_slice(&compressed_body);
|
|
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
|
pdf.extend_from_slice(format!("startxref\n{xref_offset}\n%%EOF").as_bytes());
|
|
pdf
|
|
}
|
|
|
|
fn incremental_classic_xref_pdf(objects: usize) -> Vec<u8> {
|
|
let mut pdf = b"%PDF-1.4\n".to_vec();
|
|
let mut offsets = Vec::with_capacity(objects);
|
|
offsets.push(pdf.len());
|
|
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
|
|
for id in 2..=objects {
|
|
offsets.push(pdf.len());
|
|
pdf.extend_from_slice(format!("{id} 0 obj\n{id}\nendobj\n").as_bytes());
|
|
}
|
|
|
|
let first_xref = pdf.len();
|
|
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects + 1).as_bytes());
|
|
for offset in offsets {
|
|
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
|
}
|
|
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\n", objects + 1).as_bytes());
|
|
pdf.extend_from_slice(format!("startxref\n{first_xref}\n%%EOF\n").as_bytes());
|
|
|
|
let new_offset = pdf.len();
|
|
let new_id = objects + 1;
|
|
pdf.extend_from_slice(format!("{new_id} 0 obj\n99\nendobj\n").as_bytes());
|
|
let second_xref = pdf.len();
|
|
pdf.extend_from_slice(format!("xref\n{new_id} 1\n{new_offset:010} 00000 n \n").as_bytes());
|
|
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Prev {first_xref} >>\n", new_id + 1).as_bytes());
|
|
pdf.extend_from_slice(format!("startxref\n{second_xref}\n%%EOF").as_bytes());
|
|
pdf
|
|
}
|
|
|
|
// With `/W [0 0 0]` every entry reads zero bytes, so before the bound the loop ran
|
|
// purely on `/Index`. A 137-byte file claiming a million entries returned Ok with a
|
|
// million-node xref table; `i64::MAX` never returned at all. It must be rejected,
|
|
// and quickly.
|
|
#[test]
|
|
fn zero_width_xref_stream_is_rejected() {
|
|
let pdf = xref_stream_pdf([0, 0, 0], 1_000_000, b"");
|
|
match Document::load_mem(&pdf) {
|
|
Err(Error::Parse(ParseError::InvalidXref)) => {}
|
|
Err(other) => panic!("expected InvalidXref, got {other:?}"),
|
|
Ok(doc) => panic!(
|
|
"a 137-byte file claiming 1,000,000 xref entries loaded with {} entries",
|
|
doc.reference_table.entries.len()
|
|
),
|
|
}
|
|
}
|
|
|
|
// Non-zero widths already terminate once the reader runs dry, but only after
|
|
// inserting one map entry per byte first. Bounding the count against the bytes
|
|
// present stops that up front: three body bytes at `/W [1 1 1]` hold one entry, so
|
|
// a section claiming a million is malformed.
|
|
#[test]
|
|
fn index_count_past_stream_length_is_rejected() {
|
|
let pdf = xref_stream_pdf([1, 1, 1], 1_000_000, &[1, 0, 0]);
|
|
match Document::load_mem(&pdf) {
|
|
Err(Error::Parse(ParseError::InvalidXref)) => {}
|
|
Err(other) => panic!("expected InvalidXref, got {other:?}"),
|
|
Ok(_) => panic!("an /Index count far past the stream length was accepted"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn negative_index_count_is_rejected() {
|
|
let pdf = xref_stream_pdf([1, 1, 1], -1, &[]);
|
|
assert!(matches!(
|
|
Document::load_mem(&pdf),
|
|
Err(Error::Parse(ParseError::InvalidXref))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn three_byte_xref_records_are_accepted() {
|
|
let pdf = xref_stream_pdf([1, 2, 0], 2, &[0, 0, 0, 1, 0, 9]);
|
|
Document::load_mem(&pdf).expect("/W [1 2 0] records should remain accepted");
|
|
}
|
|
|
|
#[test]
|
|
fn single_index_section_matching_stream_length_is_accepted() {
|
|
let body = [1, 0, 0, 0, 9, 0, 0];
|
|
let pdf = xref_stream_pdf_with_index([1, 4, 2], "0 1", &body);
|
|
Document::load_mem(&pdf).expect("one seven-byte record should fit a seven-byte body");
|
|
}
|
|
|
|
#[test]
|
|
fn total_index_count_past_stream_length_is_rejected() {
|
|
let body = [1, 0, 0, 0, 9, 0, 0];
|
|
let pdf = xref_stream_pdf_with_index([1, 4, 2], "0 1 2 1", &body);
|
|
assert!(matches!(
|
|
Document::load_mem(&pdf),
|
|
Err(Error::Parse(ParseError::InvalidXref))
|
|
));
|
|
}
|
|
|
|
// `/W [0 1 0]` records consume one decoded byte but retain a full map entry.
|
|
// Floor the effective width so a valid-sized body cannot amplify into a much
|
|
// larger in-memory xref table, independently of any decompression budget.
|
|
#[test]
|
|
fn degenerate_narrow_xref_records_are_rejected() {
|
|
let pdf = xref_stream_pdf([0, 1, 0], 4, &[0; 4]);
|
|
let result = Document::load_mem(&pdf);
|
|
assert!(matches!(result, Err(Error::Parse(ParseError::InvalidXref))));
|
|
}
|
|
|
|
#[test]
|
|
fn realistic_compressed_xref_stream_loads_under_budget() {
|
|
let pdf = compressed_xref_stream_pdf(300);
|
|
Document::load_mem_with_options(&pdf, LoadOptions::with_max_decompressed_size(2_000))
|
|
.expect("a 1,500-byte xref stream should load under a 2,000-byte budget");
|
|
}
|
|
|
|
#[test]
|
|
fn incremental_classic_xref_loads_under_budget() {
|
|
let pdf = incremental_classic_xref_pdf(400);
|
|
Document::load_mem_with_options(&pdf, LoadOptions::with_max_decompressed_size(2_000))
|
|
.expect("classic xref entries should not consume the decompression budget");
|
|
}
|
|
|
|
// The bound is derived from the real per-entry width, so a genuine xref stream
|
|
// whose `/Index` matches its body still loads. Written by lopdf's own writer to be
|
|
// sure the shape is exactly what the reader expects.
|
|
#[test]
|
|
fn valid_xref_stream_still_loads() {
|
|
let mut doc = Document::with_version("1.5");
|
|
let content_id = doc.add_object(Stream::new(dictionary! {}, b"BT ET".to_vec()));
|
|
let pages_id = doc.new_object_id();
|
|
let page_id = doc.add_object(dictionary! {
|
|
"Type" => "Page",
|
|
"Parent" => lopdf::Object::Reference(pages_id),
|
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
|
"Contents" => lopdf::Object::Reference(content_id),
|
|
});
|
|
doc.objects.insert(
|
|
pages_id,
|
|
lopdf::Object::Dictionary(dictionary! {
|
|
"Type" => "Pages",
|
|
"Kids" => vec![lopdf::Object::Reference(page_id)],
|
|
"Count" => 1,
|
|
}),
|
|
);
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => lopdf::Object::Reference(pages_id),
|
|
});
|
|
doc.trailer.set("Root", lopdf::Object::Reference(catalog_id));
|
|
|
|
let options = SaveOptions::builder()
|
|
.use_object_streams(false)
|
|
.use_xref_streams(true)
|
|
.build();
|
|
let mut buffer = Vec::new();
|
|
doc.save_with_options(&mut buffer, options).unwrap();
|
|
|
|
let reloaded = Document::load_mem(&buffer).unwrap();
|
|
assert!(reloaded.get_object(catalog_id).is_ok());
|
|
assert_eq!(reloaded.get_pages().len(), 1);
|
|
}
|