Technical Article

PDFium Component DocMDP: How a Widget /P Hid Page Edits

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 analyser 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 modelled, 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 analyser 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:

  1. The signature widget is a /Subtype /Widget with /FT /Sig, so it gets the annotation role
  2. The widget's /P pushes the annotation role onto the page dictionary, which already has the page role
  3. The page pushes both roles into /Contents, /Resources and, through /Parent, up into the Pages tree and across to every sibling page
  4. 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
PDFium Component diagram of the pre-v3.126.2 DocMDP role graph where a signature widget /P back-reference pushes the annotation role onto the page dictionary, the page spreads it through /Contents onto a content stream with no /Type entry, the classifier outputs prckAnnotation and P=3 grading returns prdAllowed
Before v3.126.2 the role graph treated every indirect reference as ownership, so the widget /P entry pushed the annotation role onto the page and a genuine page edit left the analyser as a permitted annotation change

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 mislabelled 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 defence 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 summarises the navigation keys that no longer carry ownership

Owner dictionaryKeys treated as navigationSpec reference
Page or Pages node/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Annotation or widget/PISO 32000-1 §12.5.2
Widget or field dictionary/ParentISO 32000-1 §12.7.3
PDFium Component v3.126.2 object graph separating owned-payload edges such as /Contents and /Annots, which spread page and annotation roles, from navigation edges such as widget /P, which carry no roles, with the navigation keys per owner dictionary and prckPageContent kept on the content stream even under a forged /Type
v3.126.2 keeps back-references out of role propagation: roles travel only through real ownership, so the content stream stays page content and the /P hint decides nothing

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 analyser does not guess which copy a viewer would use; the role build fails and the signature becomes Indeterminate

Several further rules close the remaining relabelling 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 /Parent walk 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 prckPageContent even if a later revision rewrites it with a forged /FT, a /Type /Annot label, 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 /FT resolves the inherited field type through the /Parent chain, 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 is prdDisallowed
  • With FieldMDP Include or Exclude, the analyser cannot trace a shared scalar back to one field name, so the decision is prdIndeterminate rather than a guess
  • Without FieldMDP, the P=3 annotation rule applies and the change stays allowed
PDFium Component FieldMDP decision diagram where a locked Total field /V and an annotation /Contents reference the same indirect object, branching over DocMDP P=2, FieldMDP All, FieldMDP Include or Exclude and no FieldMDP to prdDisallowed, prdIndeterminate or prdAllowed verdicts for the same shared edit
When one indirect object carries both the annotation and form roles, the annotation decision re-checks the FieldMDP lock, so the same edit ranges from allowed to disallowed to indeterminate

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 analyser 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 prasAllowed for page content edits in DocMDP P=3 documents; re-run TPdf.AnalyzeSignatureRevisions on 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 prasNoLaterChanges and prasAllowed; treat prasIndeterminate and prasSuspicious as untrusted
  • Test Report.Risks as well as Report.Status, because prrDuplicateObjectDefinition does not change the status by itself
  • Do not read an empty Changes array as a clean result when the signature status is Indeterminate; a failed role build reports no changes
  • Do not rely on prrFieldMdpUnresolved alone 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
  • Analyse the original file bytes; a document rewritten by SaveAs no 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