[ 
https://issues.apache.org/jira/browse/PDFBOX-6252?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Gustavo A. updated PDFBOX-6252:
-------------------------------
    Attachment: free-object-after-addpage.pdf
                free-object-input.pdf
                IncrementalXrefRepro.java
                topfree-after-incremental.pdf
                topfree-input.pdf
                XrefReuseRepro.java
                XrefStreamRepro.java
                XrefStreamTopFree.java

> PDDocument.saveIncremental() writes an update section that contradicts the 
> sections it extends, on a document whose cross-reference marks an object free
> --------------------------------------------------------------------------------------------------------------------------------------------------------
>
>                 Key: PDFBOX-6252
>                 URL: https://issues.apache.org/jira/browse/PDFBOX-6252
>             Project: PDFBox
>          Issue Type: Bug
>    Affects Versions: 3.0.8 PDFBox, 4.0.0
>            Reporter: Gustavo A.
>            Priority: Major
>         Attachments: IncrementalXrefRepro.java, IncrementalXrefRepro20.java, 
> XrefReuseRepro.java, XrefStreamRepro.java, XrefStreamTopFree.java, 
> free-object-after-addpage.pdf, free-object-input.pdf, 
> topfree-after-incremental.pdf, topfree-input.pdf
>
>
> h2. Summary
> {{PDDocument#saveIncremental}} derives the update section's trailer and 
> cross-reference entries
> from the objects it holds in memory, rather than from the combination of its 
> own section with the
> ones already in the file. On a document whose cross-reference marks an object 
> free, the section it
> appends contradicts the sections it extends in three ways: {{/Size}} shrinks, 
> the free list is
> discarded, and a number the previous section maps into that free list is 
> handed out to a new
> object.
> No signing is involved, and neither is a cross-reference stream nor a file 
> produced by an older
> PDFBox: the input built by the attached reproducers is a five-object PDF 
> written by hand. Measured
> identically on 3.0.6, 3.0.7, 3.0.8, and on the 3.0.9-SNAPSHOT and 
> 4.0.0-SNAPSHOT builds published
> on 2026-09-03 (JDK 25). None of the three is a regression, and none is fixed 
> in trunk.
> h2. Steps to reproduce
> All the save does is this, on the attached {{{}free-object-input.pdf{}}}:
> {code:java}
> try (PDDocument doc = Loader.loadPDF(input))
> {
>     doc.getDocumentInformation().setTitle("a change, so the save has 
> something to write");
>     // doc.addPage(new PDPage());   // uncomment for reading 3 below
>     doc.saveIncremental(out);
> }
> {code}
> {{IncrementalXrefRepro.java}} is attached and does exactly that. It needs 
> nothing but PDFBox and
> its own runtime dependencies, and builds the same input in memory, so it can 
> be run on its own:
> {noformat}
> java -cp 
> "pdfbox-3.0.8.jar;pdfbox-io-3.0.8.jar;fontbox-3.0.8.jar;commons-logging-1.4.0.jar"
>  IncrementalXrefRepro.java
> {noformat}
> The input is a five-object PDF whose cross-reference table covers 0..6 in a 
> single subsection,
> declares {{{}/Size 7{}}}, marks object 6 free with the free list linked 0 -> 
> 6 -> end, and whose
> catalog references it as {{{}/Outlines 6 0 R{}}}. That is the shape a 
> document takes when an exporter
> drops an outline tree but leaves the reference behind; it is common enough in 
> office-suite output
> that we ran into it in production. The program changes only the 
> document-information title, so the
> update section is as small as an update section gets.
> The reproducer runs the same input a second time with nothing referencing 
> object 6 at all. The update section comes out identical, so the dangling 
> reference is not what is at fault.
> There is no exception and so no stack trace. With logging active, nothing is 
> written at INFO level or above during either the load or the save; that 
> silence is part of what is being reported, and the PDFBOX-5382 section below 
> covers why {{SigUtils.checkCrossReferenceTable}} does not catch it either.
> h2. Expected result
> The appended section, combined with the one it extends, still describes the 
> same address space:
> {{/Size}} stays at least 7, object 6 stays free and reachable from the head 
> of the free list, and
> any object the save adds gets a number no previous section has already spoken 
> for.
> h2. Actual result
> {noformat}
> original cross-reference section
>   /Size            : 7
>   free-list head   : 0000000006 65535 f   (object 0 -> object 6)
>   object 6         : free, end of the list
>   catalog          : /Outlines 6 0 R
> update section written by saveIncremental
>   /Size            : 6
>   free-list head   : 0000000000 65535 f
> {noformat}
> h3. 1. /Size shrinks
> The input declares {{{}/Size 7{}}}; the update section declares {{{}/Size 
> 6{}}}. Table 15 defines it as
> "the total number of entries in the file's cross-reference table, as defined 
> by the combination of
> the original section and all update sections" – seven entries here, numbered 
> 0 to 6 – and goes on
> to say that "any object in a cross-reference section whose number is greater 
> than this value shall
> be ignored and defined to be missing by a conforming reader". So this is not 
> a cosmetic
> discrepancy: the spec instructs readers to treat object 6 as gone, while the 
> catalog still
> references it as {{{}/Outlines 6 0 R{}}}.
> 7.5.6 says the same thing from the other side: "The added trailer shall 
> contain all the entries
> except the Prev entry (if present) from the previous trailer, whether 
> modified or not". And 7.5.4
> requires the combined table to "contain one entry for each object number from 
> 0 to the maximum
> object number defined in the file, even if one or more of the object numbers 
> in this range do not
> actually occur in the file", which a lowered {{/Size}} silently undoes.
> h3. 2. The free list is broken
> The update section writes {{{}0 1 / 0000000000 65535 f{}}}, declaring the 
> free list empty, while the
> previous section still marks object 6 free with generation 0. 7.5.4 gives 
> free entries exactly two
> lawful shapes: members of the linked list headed by object 0, or entries that 
> "link back to object
> number 0 and have a generation number of 65,535, even though these entries 
> are not in the linked
> list itself". After the update, object 6 is neither: it is not reachable from 
> the head, and its
> generation is 0, not 65,535.
> Annex H.7 shows what that entry is {_}for{_}. In H.7.3, an update section 
> that frees two objects writes
> {{{}0 1 / 0000000008 65535 f{}}}, moving the head onto the object it just 
> freed; in H.7.4, which reuses
> both of them, it writes {{{}0000000000 65535 f{}}}, because by then the list 
> really is empty. That
> entry is how the head is carried across revisions. PDFBox writes the second 
> form unconditionally.
> The same example never lowers {{/Size}} either: 12, 12, 12, then 13.
> h3. 3. A number the previous section maps into its free list is handed out to 
> a new object
> The two readings above come from a save that adds nothing. If the same save 
> adds any object – a
> {{PDPage}} in the attached {{{}XrefReuseRepro.java{}}}, though the type is 
> irrelevant – that object is
> written as {{{}6 0 obj{}}}, the number the previous section marks free and 
> the catalog still
> references. Read back, {{/Outlines 6 0 R}} from the earlier revision resolves 
> to the new page, in
> PDFBox itself and in pypdf 6.16.2:
> {noformat}
> BEFORE   /Outlines -> IndirectObject(6, 0)   "Object 6 0 not defined."   
> (dangling)
> AFTER    /Outlines -> IndirectObject(6, 0)   {'/Type': '/Page', '/MediaBox': 
> [...]}
> {noformat}
> Reusing a free number is legal on its own – 7.5.4 makes the free entry's 
> generation the one to use
> on reuse – so the objection is not the reuse. It is that PDFBox is not 
> choosing to reuse anything:
> the number looks unallocated, because the free entry is not in the model. 
> When the earlier revision
> is signed, what it says changes underneath it.
> h2. What decides it is the seed, not the kind of section
> {{/Size}} is written as _highest object number written_ + 1, and the 
> numbering starts from
> {{{}COSDocument#getHighestXRefObjectNumber(){}}}, which counts only numbers 
> that have an in-use entry.
> So all three readings follow from one thing: numbers the previous sections 
> put in the free list are
> invisible, and everything the writer decides is measured from a ceiling that 
> is therefore too low.
> Two further reproducers make that concrete rather than asserted:
>  * {{XrefStreamRepro.java}} – a cross-reference _stream_ input with object 6 
> free but the xref
> stream object (7) above it. {{/Size}} comes out right (8 -> 9) and the one 
> new object the save
> writes, its own {{/XRef}} stream, is numbered 8, because the visible ceiling 
> happens to be above
> the free number. Only reading 2 shows.
>  * {{XrefStreamTopFree.java}} – the same, with the free objects at the _top_ 
> of the address space
> (1..5 in use, 6 the xref stream, 7 and 8 free, {{{}/Size 9{}}}, catalog 
> {{{}/Outlines 7 0 R{}}}). Here
> {{/Size}} drops to 8 on every build tested, and the update's own 
> cross-reference stream is written
> as object {*}7{*}, so {{/Outlines 7 0 R}} resolves to the {{/XRef}} 
> dictionary itself – the same
> reading PDFBOX-6236 describes, reached from a free entry instead of a missing 
> one. For a
> cross-reference stream the requirement is stated even more directly than in 
> table 15: table 17
> defines {{/Size}} as "the number one greater than the highest object number 
> used in this section
> {_}or in any section for which this shall be an update{_}" – 9 here, not 8.
> So reading 1 is not specific to cross-reference tables; it appears wherever 
> free numbers sit above
> the highest in-use one.
> Incidentally, {{XrefStreamRepro.java}} also shows PDFBOX-6176 taking effect. 
> The update's own xref
> stream is object 8, and 3.0.6 and 3.0.7 omit its entry:
> {noformat}
> 3.0.6, 3.0.7                        /Index [0 1 5 1]
> 3.0.8, 3.0.9-SNAPSHOT, 4.0.0-SNAP   /Index [0 1 5 1 8 1]
> {noformat}
> h2. Suggested area to investigate
> Reading trunk as of 2026-09-03:
>  * {{COSWriter#write(PDDocument, SignatureInterface)}} seeds the allocator 
> with
> {{{}number = pdDocument.getDocument().getHighestXRefObjectNumber(){}}}.
>  * {{COSWriter#doWriteTrailer(COSDocument)}} writes 
> {{trailer.setLong(COSName.SIZE, number + 1)}}
> for the non-xref-stream case; the stream path does the equivalent in 
> {{doWriteXRefInc}} with
> {{{}pdfxRefStream.setSize(number + 1){}}}.
>  * {{COSWriter#doWriteXRefTable()}} writes the free-list entry 
> unconditionally on the incremental
> branch: {{{}addXRefEntry(FreeXReference.NULL_ENTRY){}}}. The 
> {{fillGapsWithFreeEntries()}} next to it
> runs only when the save is _not_ incremental.
>  * The cross-reference stream path arrives at the same entry by a different 
> route:
> {{PDFXRefStream#getIndexEntry()}} always adds object 0 to the index, and
> {{PDFXRefStream#writeStreamData()}} always writes 
> {{FreeXReference.NULL_ENTRY}} as the first entry
> of the stream. So reading 2 is not specific to tables either.
> h2. Suggested fix
> All three come from the same place – the update section is computed from 
> PDFBox's object model
> rather than from the file it extends – and each has a narrow fix:
>  * {*}Number allocation{*}: seed it with {{max(getHighestXRefObjectNumber(), 
> previous trailer /Size - 1)}}
> rather than with {{getHighestXRefObjectNumber()}} alone. The previous trailer 
> already declares the
> address space the file claims, whether or not every number in it has an 
> entry, so object 6 stops
> looking unallocated without the writer having to learn what the free list 
> holds. On a well-formed
> file the two values agree and nothing changes. The same rule also covers 
> PDFBOX-6236, where the
> invisible number is a cross-reference stream object rather than a free one.
>  * {*}The Size entry{*}: take {{max(previous trailer /Size, highest object 
> number written + 1)}}
> instead of a count of the in-memory objects. An incremental update can never 
> lower the address space of the file
> it extends.
>  * {*}Free list{*}: carry the previous head forward instead of writing 
> {{0000000000 65535 f}}
> unconditionally, which is what annex H.7's own update sections do. Omitting 
> the entry altogether is
> equally correct and even smaller: 7.5.6 asks an update section for "entries 
> only for objects that
> have been changed, replaced, or deleted", and object 0 has not changed.
> h2. What is not being suggested
> Since it is the natural next question: please do not repair the document 
> before an incremental
> save. Rewriting anything in the existing bytes would break every signature 
> already in the file,
> which is exactly what an incremental save exists to avoid. Any repair has to 
> be appended as its own
> revision, by the caller, before the first signature. That is what we ended up 
> doing on our side,
> and it is out of scope for PDFBox.
> Likewise, a strict mode that refuses malformed input would run against 
> PDFBox's own design;
> tolerating broken documents is a large part of why people reach for it. The 
> ask here is only that
> the section PDFBox itself writes not contradict the ones it is extending.
> h2. Relation to PDFBOX-6236
> That issue is the closest sibling, and the mechanism looks like the same one. 
> There, a new field
> object is given the number of the increment's own {{/XRef}} stream, because 
> that object had no
> entry in its own revision's cross-reference, so to the writer the number 
> looked unallocated.
> PDFBOX-6176 fixed the missing entry in 3.0.8.
> What is reported here is the same "the number looks unallocated" condition 
> arriving from a
> different direction – a free entry rather than a missing one – and that 
> direction is _not_ fixed
> in 3.0.8.
> Measured on that issue's own attachment, for what it is worth: loading 
> {{already-signed.pdf}} with
> 3.0.8 reports {{getHighestXRefObjectNumber() = 42}} while the newest trailer 
> of the file says
> {{{}/Size 44{}}}, so {{addPage}} plus {{saveIncremental}} writes the new page 
> as object 43 – the number
> revision 4's cross-reference stream already occupies at offset 76040. PDFBox 
> reads the result as
> two pages, pypdf 6.16.2 as one. Identical on all five builds tested. Both 
> cases share the seed, and
> the first suggestion above fixes both.
> h2. Relation to PDFBOX-5382
> All three readings look like the write-side consequence of the limitation 
> discussed there. In that
> thread, {{SigUtils.checkCrossReferenceTable}} was added in r1898484/r1898485 
> and wired into the
> three signing examples in r1898487/r1898488; r1899085/r1899086 then reverted 
> the call sites only
> ("revert, but keep a comment for now"), after a discussion in which Michael 
> Klink pointed out that
> {{{}COSDocument{}}}'s cross-reference {{Map}} holds no entries for free 
> objects, so an object number
> mapped into the free list cannot be told apart from one that is absent 
> altogether. The method
> itself was never removed; it is in trunk today, and {{CreateSignature}} still 
> carries the line
> {{{}// call SigUtils.checkCrossReferenceTable(document) if Adobe 
> complains{}}}.
> That is the same missing information, one step later: because the free 
> entries are gone by the time
> the update section is written, the writer has no free list to carry forward 
> and no object 6 to
> count towards {{{}/Size{}}}.
> The difference worth drawing out is the direction. PDFBOX-5382 is about 
> _noticing_ a gap in a
> document that arrives with one. What is reported here is on the other side: 
> given a document where
> object 6 is mapped into the free list, which is a legitimate mapping, an 
> incremental save hands
> back one where it is not, because the newest {{/Size}} no longer covers it 
> and the newest free-list
> head no longer leads to it.
> Worth stating plainly, since it is the obvious question: 
> {{SigUtils.checkCrossReferenceTable}} does
> *not* warn on either file, before or after the save. 
> {{COSDocument#getXrefTable()}} comes back with
> keys 1..5 and {{getHighestXRefObjectNumber()}} with 5, so object 6 is absent 
> from the model and
> nothing looks missing to the check. A control with an actual hole (object 4 
> defined in no section)
> does produce the warning, so this is not a logging artefact. The blind spot 
> is the same one Michael
> Klink described in 2022; the point here is only that the writer sits behind 
> it too.
> h2. Impact
> A conforming reader is instructed by table 15 to treat object 6 as missing 
> after the save, and the
> free list the file used to have is gone. Where it bites hardest is a signed 
> document: references
> made by an earlier, signed revision can be made to resolve to different 
> objects by a later
> incremental save, without any of the existing bytes being touched.
> On the reader side we can offer a measurement rather than the "may fail 
> validation in Acrobat
> Reader" of PDFBOX-5382, though it needs stating precisely, because it is a 
> necessary condition and
> not a sufficient one. Bisecting roughly thirty signed variants of one 
> document, Acrobat reports an
> earlier signature as "altered or corrupted" as soon as any later revision is 
> appended – even one
> whose entire content is a single object nothing references – when the signed 
> document has *both* a
> gap in the object numbering *and* annotations written inline in a page's 
> {{/Annots}} (where ISO
> 32000-1 table 30 requires indirect references). Repairing either one alone 
> left the signature
> invalid; repairing both made it valid. So the gap is half of a reproducible 
> Acrobat failure, not
> the whole of it, and the other half is nothing to do with PDFBox. What is 
> squarely PDFBox's is that
> an incremental save _introduces_ the condition: given a document whose 
> {{/Size}} is 7, it hands
> back one whose newest section says 6.
> A last note for completeness, since it bears on how much of this is worth 
> chasing: the document
> that started our investigation broke identically under a signing stack that 
> uses no PDFBox at all.
> The defect that caused it was in the source PDF, and we fixed it on our side 
> by appending a repair
> revision before the first signature. Nothing in this report is offered as the 
> cause of that.
> h2. Attachments
> The PDFs are the inputs and outputs of the two table-based readings, in case 
> it is easier to look
> at the bytes than to run anything. They are synthetic, a few hundred bytes 
> each, and contain no
> data of any kind.
>  * {{free-object-input.pdf}} – the five-object input: {{{}/Size 7{}}}, object 
> 6 free, list 0 -> 6 ->
> end, catalog {{{}/Outlines 6 0 R{}}}.
>  * {{free-object-after-addpage.pdf}} – the same file after {{addPage}} plus 
> {{saveIncremental}} on
> 3.0.8, where {{/Outlines 6 0 R}} now resolves to the new page.
>  * {{topfree-input.pdf}} and {{topfree-after-incremental.pdf}} – the 
> cross-reference stream pair,
> where {{/Size}} drops from 9 to 8 and {{/Outlines 7 0 R}} ends up resolving 
> to the update's own
> {{/XRef}} dictionary.
>  * {{IncrementalXrefRepro.java}} – readings 1 and 2, cross-reference table 
> input, run twice (with
> and without a reference to the free object).
>  * {{XrefReuseRepro.java}} – reading 3, same input, the save adds a 
> {{{}PDPage{}}}.
>  * {{XrefStreamRepro.java}} – cross-reference stream input whose free number 
> is below the highest
> in-use one; only reading 2 shows.
>  * {{XrefStreamTopFree.java}} – cross-reference stream input whose free 
> numbers are above it; the
> update's own {{/XRef}} stream takes a referenced free number.
> All four run the same way as the first, needing only PDFBox and its runtime 
> dependencies – on
> 4.0.0-SNAPSHOT, {{log4j-api}} as well.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to