In PDFium Component builds before v3.126.2, TPdf.AnalyzeSignatureRevisions could grade a real page content edit as a permitted annotation change under DocMDP P=3, because its revision role graph treated the signature widget's /P back-reference to its page as ownership. Since v3.126.2, PDFium Component separates navigation edges from owned-payload edges, so page content stays page content. The bug report behind this fix looks harmless on paper. A certified contract allows annotations, a counterparty adds one incremental save, and the analyzer says every later change is allowed. Then someone diffs the rendered pages and the payment amount on page 2 is different
This article is the attacker's-eye sequel to the post-signature revision change analysis overview, so it skips the basics of revision rebuilding and DocMDP grading and goes straight to the object graph: how ownership was modeled, why the direction of an edge decides a security verdict, what changed in v3.126.2, and how to audit your own acceptance logic
Why did a page edit pass as an annotation change under DocMDP P=3?
The page edit passed because the old role graph followed every indirect reference in a dictionary as if the referenced object belonged to the referring one, and the signature widget points back at its page. An annotation dictionary carries /P, an indirect reference to the page object it sits on (ISO 32000-1 §12.5.2). That entry is a navigation hint. The widget does not own the page; the page owns the widget through its /Annots array
The analyzer assigns each object a set of role bits before it grades later changes: page, annotation, form and validation material. Root objects get their role from their own dictionary, and the role then spreads to everything they reference. In the old propagation, the chain went like this:
- The signature widget is a
/Subtype /Widgetwith/FT /Sig, so it gets the annotation role - The widget's
/Ppushes the annotation role onto the page dictionary, which already has the page role - The page pushes both roles into
/Contents,/Resourcesand, through/Parent, up into the Pages tree and across to every sibling page - A content stream dictionary such as
<< /Length 812 >>has no/Type, so the classifier fell back to role bits and checked the annotation role before the page role
The modified content stream therefore came out as prckAnnotation. Under ISO 32000-1 §12.8.2.2, DocMDP P=3 permits annotation changes, so the decision was prdAllowed and the report rolled up to prasAllowed. The same file under P=2 was rejected, but only by coincidence: P=2 forbids annotation changes, so the mislabeled stream was refused for the wrong reason. A fixed four-pass propagation loop added a second weakness. Payload reached through an indirect array, or through a long chain whose object numbers run backwards, might never receive any role at all
Why must a signature validator ask who owns an object?
A signature validator must ask who owns an object because PDF incremental updates (ISO 32000-1 §7.5.6) let anyone append a revision that redefines an existing object number, and the redefined body does not announce what it is. The signature still verifies, since it covers only the bytes of its own revision. Every defense against post-signing manipulation therefore depends on mapping each changed object to the structure that uses it, and then asking whether the signer allowed that structure to change
Several published attack classes work exactly in that gap. Incremental saving attacks append a revision that swaps page content and rely on the verifier checking only the signed byte range. Shadow attacks plant hidden content before signing and activate it afterward with a small, innocent-looking change. Attacks on certified documents abuse the fact that P=2 and P=3 explicitly permit some later edits, then dress a forbidden edit up as a permitted one. A verifier that classifies objects by labels such as /Type /Annot, or by any reference path that happens to reach them, is exposed to the third class: the attacker only needs one permitted structure that can reach the forbidden one
That is why the question is not which objects changed but who owns them. A content stream reached from a page through /Contents is page content no matter what else points at it. An annotation pointing back at the page through /P says where the annotation lives, not what it owns
How does PDFium Component v3.126.2 model ownership?
PDFium Component v3.126.2 treats back-references as navigation, keeps them out of role propagation, and decides which keys count as navigation from the structural role of the dictionary that holds them, not from the key name alone. The table summarizes the navigation keys that no longer carry ownership
| Owner dictionary | Keys treated as navigation | Spec reference |
|---|---|---|
| Page or Pages node | /Parent, /Kids, /Annots | ISO 32000-1 §7.7.3 |
| Annotation or widget | /P | ISO 32000-1 §12.5.2 |
| Widget or field dictionary | /Parent | ISO 32000-1 §12.7.3 |
Filtering by key name globally would have created a new hole. A font or XObject resource can legitimately be named /P, /Parent or /Annots, and a /Resources dictionary that drops its /P entry from propagation would let an attacker hide a page-owned XObject behind an innocent resource name. In v3.126.2 the navigation filter applies only when the owning dictionary actually is a page, Pages node, annotation, widget or field. If one of those dictionaries carries a duplicated navigation key, such as two /P entries in a widget, the analyzer does not guess which copy a viewer would use; the role build fails and the signature becomes Indeterminate
Several further rules close the remaining relabeling routes:
- Pages nodes are page-role roots in their own right, so resources inherited from the Pages tree (ISO 32000-1 §7.7.3.4) enter page context through real ownership, not through a
/Parentwalk from a child page - An annotation or form role arriving at a catalog, Pages node, page, annotation or field dictionary stops there, because those structural objects establish their own roles and an incoming payload role must not override them
- The page role is authoritative during classification: a page-owned object is
prckPageContenteven if a later revision rewrites it with a forged/FT, a/Type /Annotlabel, or shares it with an appearance stream - A Form XObject used only as a field or annotation appearance keeps its form or annotation category, so ordinary appearance regeneration after a form fill is still graded under the normal permission rules
- A widget without its own
/FTresolves the inherited field type through the/Parentchain, and an unresolvable chain fails the role build instead of defaulting to annotation - Role bits from every later revision are merged into the covered revision's roles, so a later update cannot erase an earlier page-ownership relationship by detaching a stream first and editing it afterward
Fixed point instead of a fixed pass count
Role reachability in v3.126.2 runs as a work queue that iterates until no object gains a new role bit, which is a true fixed point regardless of chain depth or object numbering. Indirect arrays such as a /Contents array stored as its own object are walked as well. Each object can gain at most four distinct role bits, so the queue is bounded at four entries per object number; exceeding that budget raises prrResourceLimitExceeded. A reference to a free object, a generation mismatch or a broken object header raises prrMalformedRevisionChain, and payload inside a compressed object stream raises prrCompressedObjectUnresolved. Every one of these failures ends with prasIndeterminate, never with an allowed verdict, and when the failure happens while building the covered revision's roles the signature reports no Changes at all
The following routine lists the page-content edits that survive this analysis. A prckPageContent change is never graded prdAllowed: DocMDP P=1, 2 or 3 makes it prdDisallowed, and a signature without DocMDP grades it prdSuspicious
uses
SysUtils, TypInfo, PDFium, FPdfPades;
procedure ListPageContentEdits(const FileName: string);
const
ShadowTag: array[Boolean] of string = (' (unreferenced shadow)', '');
var
Pdf: TPdf;
Report: TPadesRevisionAnalysisReport;
Sig: TPadesSignatureRevisionAnalysis;
Change: TPadesRevisionObjectChange;
i, j: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Report := Pdf.AnalyzeSignatureRevisions; // record, nothing to free
for i := 0 to High(Report.Signatures) do
begin
Sig := Report.Signatures[i];
if not (prrPageContentChanged in Sig.Risks) then
Continue;
Writeln(Format('Signature %d (DocMDP P=%d): page content changed',
[Sig.SignatureIndex, Sig.DocMdpPermission]));
for j := 0 to High(Sig.Changes) do
begin
Change := Sig.Changes[j];
if Change.Kind <> prckPageContent then
Continue;
Writeln(Format(' revision %d object %d %d R %s%s',
[Change.RevisionIndex, Change.ObjectNumber, Change.Generation,
GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Change.Decision)),
ShadowTag[Change.IsAuthoritative]]));
end;
end;
finally
Pdf.Free;
end;
end;
What happens when FieldMDP and an annotation share one object?
When a field's /V and an annotation's /Contents point at the same indirect object, v3.126.2 keeps the FieldMDP lock in force even though the change is classified as an annotation edit. The scenario is easy to build by hand: a signer locks the Total field with FieldMDP (ISO 32000-1 §12.8.2.4), and the attacker makes a text annotation's /Contents reference the same string object that holds the field value. Under P=3 the annotation edit is permitted, so before the fix rewriting that shared string changed a locked field value with an allowed verdict
The object now carries both the annotation and form roles, and the annotation decision re-checks the form side whenever the signature has a FieldMDP transform:
- Under P=2 the annotation change is disallowed outright, exactly as before
- With FieldMDP
All, every field is locked, so the shared change isprdDisallowed - With FieldMDP
IncludeorExclude, the analyzer cannot trace a shared scalar back to one field name, so the decision isprdIndeterminaterather than a guess - Without FieldMDP, the P=3 annotation rule applies and the change stays allowed
One reporting detail matters for gate code. The shared case is reported as Kind = prckAnnotation with Decision = prdIndeterminate, and prrFieldMdpUnresolved is added to the risk set only for changes classified as form fields. A gate that searches for prrFieldMdpUnresolved and ignores Status misses this case completely
How should Delphi code fail closed on revision analysis?
Delphi code should accept a signed document only when the analysis status is prasNoLaterChanges or prasAllowed and no structural risk is present, and it should treat prasIndeterminate and prasSuspicious as untrusted, not as warnings to log and pass. Indeterminate means the analyzer could not prove the later revisions were permitted; for an attacker, an input that reliably produces Indeterminate is as useful as one that produces Allowed if your code lets it through. The global AnalyzePadesSignatureRevisions takes any TStream and reads it from position 0, which suits upload handlers that never need to render the document
uses
Classes, SysUtils, TypInfo, FPdfPades;
const
// Duplicate definitions are recorded without downgrading Status
BlockingRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrCompressedObjectUnresolved,
prrResourceLimitExceeded];
function SignedRevisionsAcceptable(const FileName: string;
out Reason: string): Boolean;
var
Source: TFileStream;
Report: TPadesRevisionAnalysisReport;
begin
Result := False;
Reason := '';
Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Report := AnalyzePadesSignatureRevisions(Source);
finally
Source.Free;
end;
if Report.SignatureCount = 0 then
begin
Reason := 'no signature anchors the analysis';
Exit;
end;
if Report.Risks * BlockingRisks <> [] then
begin
Reason := 'structural risk in the revision chain';
Exit;
end;
case Report.Status of
prasNoLaterChanges, prasAllowed:
Result := True;
else
// prasIndeterminate and prasSuspicious are rejections, not warnings
Reason := 'revision status ' +
GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
end;
end;
Two boundaries are worth stating plainly. TPadesRevisionAnalysisReport says nothing about CMS integrity or certificate trust, so this gate sits next to cryptographic and trust validation, not in place of them. And a correct ownership graph does not make P=3 safe for every workflow. P=3 genuinely permits annotations, and an annotation with an opaque appearance can cover signed text without touching a single content stream. If your certified documents are contracts rather than review copies, either certify with P=2 or route allowed annotation changes to a human, as in this helper:
function AllowedAnnotationEditsUnderP3(
const Report: TPadesRevisionAnalysisReport): Integer;
var
i, j: Integer;
begin
Result := 0;
for i := 0 to High(Report.Signatures) do
if Report.Signatures[i].DocMdpPermission = 3 then
for j := 0 to High(Report.Signatures[i].Changes) do
if (Report.Signatures[i].Changes[j].Kind = prckAnnotation) and
(Report.Signatures[i].Changes[j].Decision = prdAllowed) then
Inc(Result);
end;
Signature revision audit checklist
Use this list to check whether your verification pipeline was exposed and whether it now fails closed:
- Builds of PDFium Component before v3.126.2 could report
prasAllowedfor page content edits in DocMDP P=3 documents; re-runTPdf.AnalyzeSignatureRevisionson certified P=3 files accepted by older builds - Re-check P=3 documents with FieldMDP locks where a field value and an annotation might share an indirect object
- Accept only
prasNoLaterChangesandprasAllowed; treatprasIndeterminateandprasSuspiciousas untrusted - Test
Report.Risksas well asReport.Status, becauseprrDuplicateObjectDefinitiondoes not change the status by itself - Do not read an empty
Changesarray as a clean result when the signature status is Indeterminate; a failed role build reports no changes - Do not rely on
prrFieldMdpUnresolvedalone to catch FieldMDP problems, since the shared annotation case surfaces only through the decision and status - Decide whether allowed annotation changes under P=3 need human review in your workflow
- Analyze the original file bytes; a document rewritten by
SaveAsno longer contains the revision chain
Revision analysis is one layer of a signature check. Pair it with inspecting PDF digital signatures and PAdES levels for the dictionary and baseline level, and with a broader PDF security risk audit for JavaScript, launch actions and embedded files. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions and the ownership-aware role graph described here ship in the PDFium Component for Delphi, C++Builder and Lazarus