lopdf: bound object lookups through compressed containers and object-stream members (#771)

A compressed object's container was resolved with a fresh cycle set and
without checking that it is an ordinary object, and indirect /Length
chains had no depth limit, so self-containing or chained lookups
overflowed the stack. Pass the caller's set on, require an in-file
container and cap the chain at 16 lookups. Object-stream members were
each parsed to the end of the decoded stream, so repeated or nested
offsets kept many copies of one large object: parse each member within
its own extent and skip repeated offsets.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ikki Nishihara 2026-10-11 04:51:25 +09:00 committed by GitHub
parent c8c77efb14
commit 559859ca61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 259 additions and 12 deletions

View File

@ -1334,6 +1334,204 @@ mod tests {
}
}
// Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`,
// `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file.
fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec<u8> {
let mut pdf = b"%PDF-1.7\n".to_vec();
let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id));
let xref_id = ids.max().unwrap_or(3) + 1;
let mut records = vec![[0u8; 7]; (xref_id + 1) as usize];
records[0] = [0, 0, 0, 0, 0, 255, 255];
let mut append = |id: u32, body: &[u8]| {
records[id as usize][0] = 1;
records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes());
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
pdf.extend_from_slice(body);
pdf.extend_from_slice(b"\nendobj\n");
};
append(1, b"<< /Type /Catalog >>");
append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream");
for &(id, body) in objects {
append(id, body);
}
for &(id, container, index) in containers {
records[id as usize][0] = 2;
records[id as usize][1..5].copy_from_slice(&container.to_be_bytes());
records[id as usize][5..7].copy_from_slice(&index.to_be_bytes());
}
let xref_offset = pdf.len();
records[xref_id as usize][0] = 1;
records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes());
pdf.extend_from_slice(
format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7)
.as_bytes(),
);
for record in records {
pdf.extend_from_slice(&record);
}
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
pdf
}
// Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last
// link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object
// stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1.
fn length_chain_pdf(hops: u32, compressed: bool) -> Vec<u8> {
let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() };
let (mut containers, mut objects) = (Vec::new(), Vec::new());
let mut value = 5; // the length of stream 2's data, "hello"
for hop in 0..hops {
if compressed {
let (id, container) = (3 + 2 * hop, 4 + 2 * hop);
let index = format!("{id} 0 ");
let data = format!("{index}{value}");
let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data));
objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream")));
containers.push((id, container, 0));
value = data.len();
} else {
objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, ""))));
}
}
let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect();
compressed_length_pdf(&containers, &objects)
}
// Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's
// dictionary without its data.
fn load_without_stream_2_data(pdf: &[u8]) -> Document {
let doc = Document::load_mem(pdf).unwrap();
assert!(doc.catalog().unwrap().has_type(b"Catalog"));
assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty());
doc
}
#[test]
fn compressed_object_self_container_does_not_recurse() {
let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[]));
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_object_mutual_containers_do_not_recurse() {
let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[]));
assert!(doc.get_object((3, 0)).is_err());
assert!(doc.get_object((4, 0)).is_err());
}
#[test]
fn object_stream_length_cycle_does_not_recurse() {
let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]);
let doc = load_without_stream_2_data(&pdf);
// The container's /Length is the object it holds: the container is kept without data, so 3 never loads.
assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty());
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_object_container_chain_does_not_recurse() {
let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect();
let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]);
let doc = load_without_stream_2_data(&pdf);
// The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused.
assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5");
assert!(doc.get_object((3, 0)).is_err());
}
#[test]
fn compressed_objects_and_indirect_stream_length_still_load() {
let pdf = compressed_length_pdf(
&[(3, 4, 0), (5, 4, 1)],
&[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")],
);
let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap();
assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value");
assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello");
}
#[test]
fn compressed_length_in_a_container_with_an_indirect_length_still_loads() {
// Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is
// a plain indirect integer.
let pdf =
compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]);
let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap();
assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello");
}
#[test]
fn compressed_length_chain_does_not_recurse() {
// Every container is an ordinary object in the file and every hop names a new object, so neither the
// container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack
// overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one,
// next to the direct /Length, does.
let hops = 5_000;
let doc = load_without_stream_2_data(&length_chain_pdf(hops, true));
assert!(doc.get_object((3, 0)).is_err());
assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok());
// The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and
// 16 are allowed, so the 9th link from the end loads and the 10th does not.
assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok());
assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err());
}
#[test]
fn plain_stream_length_chain_does_not_recurse() {
// The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack
// overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data.
let hops = 5_000;
let doc = load_without_stream_2_data(&length_chain_pdf(hops, false));
assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty()))));
}
fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream {
let mut content = Vec::new();
for &(id, offset) in pairs {
content.extend_from_slice(format!("{id} {offset} ").as_bytes());
}
let mut dict = Dictionary::new();
dict.set("Type", "ObjStm");
dict.set("N", pairs.len() as i64);
dict.set("First", content.len() as i64);
content.extend_from_slice(members);
lopdf::Stream::new(dict, content)
}
#[test]
fn object_stream_member_extents_bound_owned_strings() {
let mut members = vec![b'('; 32];
members.extend(vec![b'x'; 1 << 20]);
members.extend([b')'; 32]);
// Put the innermost (complete) string first in index order, then overlapping starts
// and duplicates. Only this first member at offset 31 may own the large string.
let mut pairs = vec![(100, 31)];
pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset)));
pairs.extend([(200, 31), (201, 31)]);
let stream = indexed_object_stream(&pairs, &members);
let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap();
assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20);
let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum();
assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len());
assert!(!parsed.objects.contains_key(&(200, 0)));
assert!(!parsed.objects.contains_key(&(201, 0)));
}
#[test]
fn object_stream_member_extents_preserve_index_order_and_whitespace() {
let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)";
// Unsorted offsets, and object 9 appears twice: its last index entry still wins.
let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)];
let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap();
assert_eq!(parsed.objects.len(), 4);
assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last");
assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name");
assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]);
assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value");
}
#[test]
fn view_and_user_space_round_trip_for_every_rotation() {
for rotation in [0u16, 90, 180, 270] {

2
vendor/README.md vendored

File diff suppressed because one or more lines are too long

View File

@ -86,31 +86,61 @@ impl ObjectStream {
warn!("object stream: the object stream dictionary specifies a wrong number of objects")
}
let chunks_filter_map = |chunk: &[_]| {
// PdfCraft patch: bound members by the next distinct offset and parse each offset
// only once, so overlapping or duplicate entries cannot amplify decoded content.
let mut extents = BTreeMap::new();
for (index, chunk) in numbers[..len].chunks(2).enumerate() {
if let &[Some(_), Some(offset)] = chunk {
let offset = first_offset.saturating_add(offset as usize);
if offset < content.len() {
extents.entry(offset).or_insert((index, content.len()));
}
}
}
let mut end = content.len();
for (&offset, (_, extent_end)) in extents.iter_mut().rev() {
*extent_end = end;
end = offset;
}
let chunks_filter_map = |(index, chunk): (usize, &[_])| {
let id = chunk[0]?;
let offset = first_offset + chunk[1]? as usize;
let offset = first_offset.saturating_add(chunk[1]? as usize);
if offset >= content.len() {
warn!("out-of-bounds offset in object stream");
return None;
}
let &(first_index, extent_end) = extents.get(&offset)?;
if index != first_index {
warn!("duplicate offset in object stream");
return None;
}
// Skip leading whitespace — some PDFs emit newlines before objects in ObjStm
let mut start = offset;
while start < content.len() && content[start].is_ascii_whitespace() {
while start < extent_end && content[start].is_ascii_whitespace() {
start += 1;
}
if start >= content.len() {
if start >= extent_end {
warn!("only whitespace after offset in object stream");
return None;
}
let object = parser::direct_object(&content[start..])?;
let object = parser::direct_object(&content[start..extent_end])?;
Some(((id, 0), object))
};
#[cfg(feature = "rayon")]
let objects = numbers[..len].par_chunks(2).filter_map(chunks_filter_map).collect();
let objects = numbers[..len]
.par_chunks(2)
.enumerate()
.filter_map(chunks_filter_map)
.collect();
#[cfg(not(feature = "rayon"))]
let objects = numbers[..len].chunks(2).filter_map(chunks_filter_map).collect();
let objects = numbers[..len]
.chunks(2)
.enumerate()
.filter_map(chunks_filter_map)
.collect();
Ok(ObjectStream {
objects,

View File

@ -493,6 +493,10 @@ pub const MAX_BRACKET: usize = 100;
pub const MAX_NESTING_DEPTH: usize = 100;
/// PdfCraft patch: a valid stream /Length resolves in at most three lookups (the length, the object stream holding
/// it, and that stream's own /Length); longer lookup chains overflowed the stack.
const MAX_LOOKUP_DEPTH: usize = 16;
/// Cap on reconstructed cross-reference entries, bounding memory on hostile inputs.
const MAX_RECONSTRUCTED_OBJECTS: usize = 1_000_000;
@ -1149,7 +1153,7 @@ impl Reader<'_> {
}
/// Load a compressed object from an object stream (for lightweight metadata extraction)
fn get_compressed_object(&self, id: ObjectId) -> Result<Object> {
fn get_compressed_object(&self, id: ObjectId, already_seen: &mut HashSet<ObjectId>) -> Result<Object> {
let entry = self.document.reference_table.get(id.0).ok_or(Error::MissingXrefEntry)?;
let container_id = match entry {
@ -1157,15 +1161,30 @@ impl Reader<'_> {
_ => return Err(Error::MissingXrefEntry),
};
// PdfCraft patch: require an in-file container (PDF 7.5.7) and retain the caller's cycle history through
// indirect stream /Length lookups; `get_object` caps the depth of that history.
if !matches!(
self.document.reference_table.get(container_id),
Some(XrefEntry::Normal { .. })
) {
return Err(Error::InvalidObjectStream(
"Object stream container must be an ordinary in-file object".into(),
));
}
let container_id = (container_id, 0);
let mut already_seen = HashSet::new();
let container_obj = self.get_object(container_id, &mut already_seen)?;
let container_obj = self.get_object(container_id, already_seen)?;
let container_stream = container_obj.as_stream()?;
let object_stream = ObjectStream::new_with_limit(container_stream, self.max_decompressed_size)?;
object_stream.objects.get(&id).cloned().ok_or(Error::MissingXrefEntry)
}
pub fn get_object(&self, id: ObjectId, already_seen: &mut HashSet<ObjectId>) -> Result<Object> {
// PdfCraft patch: each top-level lookup starts with an empty set and every nested lookup adds one object, so
// the set's size is the depth of the lookup chain.
if already_seen.len() >= MAX_LOOKUP_DEPTH {
warn!("lookup chain deeper than {MAX_LOOKUP_DEPTH} at object {} {}", id.0, id.1);
return Err(Error::ReferenceLimit);
}
if already_seen.contains(&id) {
warn!("reference cycle detected resolving object {} {}", id.0, id.1);
return Err(Error::ReferenceCycle(id));
@ -1175,7 +1194,7 @@ impl Reader<'_> {
if let Some(entry) = self.document.reference_table.get(id.0)
&& matches!(entry, XrefEntry::Compressed { .. })
{
return self.get_compressed_object(id);
return self.get_compressed_object(id, already_seen);
}
let offset = self.get_offset(id)?;