Technischer Artikel

PDFium Component DocMDP: Wie ein Widget-/P Edits versteckte

In PDFium Component Builds vor v3.126.2 konnte TPdf.AnalyzeSignatureRevisions einen echten Seiteninhalts-Edit als erlaubte Annotations-Änderung unter DocMDP P=3 werten, weil sein Revisions-Rollengraph den /P-Rückverweis des Signature-Widgets auf seine Seite als Ownership behandelte. Seit v3.126.2 trennt PDFium Component Navigations-Kanten von owned-payload-Kanten, Seiteninhalt bleibt also Seiteninhalt. Der Bug-Report hinter diesem Fix sieht auf dem Papier harmlos aus. Ein zertifizierter Vertrag erlaubt Annotationen, die Gegenseite hängt einen Incremental Save an, und der Analyzer sagt, jede spätere Änderung sei erlaubt. Dann diffed jemand die gerenderten Seiten, und der Zahlungsbetrag auf Seite 2 ist ein anderer

Dieser Artikel ist die Fortsetzung aus Angreifersicht zur Übersicht über die Post-Signature-Revisions-Änderungsanalyse, überspringt also die Grundlagen von Rebuild und DocMDP-Bewertung und geht direkt in den Objektgraphen: wie Ownership modelliert war, warum die Richtung einer Kante über ein Sicherheitsurteil entscheidet, was sich in v3.126.2 geändert hat, und wie Sie Ihre eigene Annahme-Logik auditieren

Warum ging ein Seiten-Edit als Annotations-Änderung unter DocMDP P=3 durch?

Der Seiten-Edit ging durch, weil der alte Rollengraph jedem indirekten Verweis in einem Dictionary folgte, als gehöre das referenzierte Objekt zum verweisenden, und das Signature-Widget zeigt zurück auf seine Seite. Ein Annotation-Dictionary trägt /P, einen indirekten Verweis auf das Seitenobjekt, auf dem es sitzt (ISO 32000-1 §12.5.2). Dieser Eintrag ist ein Navigations-Hinweis. Das Widget besitzt die Seite nicht; die Seite besitzt das Widget über ihr /Annots-Array

Der Analyzer weist jedem Objekt vor der Bewertung späterer Änderungen eine Menge von Rollen-Bits zu: Seite, Annotation, Form und Validierungsmaterial. Root-Objekte bekommen ihre Rolle aus ihrem eigenen Dictionary, und die Rolle breitet sich dann auf alles aus, was sie referenzieren. In der alten Propagation lief die Kette so:

  1. Das Signature-Widget ist ein /Subtype /Widget mit /FT /Sig, bekommt also die Annotations-Rolle
  2. Das /P des Widgets drückt die Annotations-Rolle auf das Seiten-Dictionary, das die Seiten-Rolle bereits hat
  3. Die Seite drückt beide Rollen in /Contents, /Resources und über /Parent hinauf in den Pages-Baum und hinüber zu jeder Geschwisterseite
  4. Ein Content-Stream-Dictionary wie << /Length 812 >> hat kein /Type, der Klassifizierer griff also auf die Rollen-Bits zurück und prüfte die Annotations-Rolle vor der Seiten-Rolle
PDFium-Component-Diagramm des DocMDP-Rollengraphen vor v3.126.2, in dem der /P-Rückverweis eines Signature-Widgets die Annotations-Rolle auf das Seiten-Dictionary drückt, die Seite sie über /Contents auf einen Content-Stream ohne /Type-Eintrag ausbreitet, der Klassifizierer prckAnnotation ausgibt und die P=3-Bewertung prdAllowed liefert
Vor v3.126.2 behandelte der Rollengraph jeden indirekten Verweis als Ownership, der /P-Eintrag des Widgets drückte also die Annotations-Rolle auf die Seite, und ein echter Seiten-Edit verließ den Analyzer als erlaubte Annotations-Änderung

Der modifizierte Content-Stream kam also als prckAnnotation heraus. Unter ISO 32000-1 §12.8.2.2 erlaubt DocMDP P=3 Annotations-Änderungen, das Urteil war also prdAllowed, und der Report rollte zu prasAllowed auf. Dieselbe Datei unter P=2 wurde abgewiesen, aber nur durch Zufall: P=2 verbietet Annotations-Änderungen, der fehlbeschriftete Stream wurde also aus dem falschen Grund abgelehnt. Eine feste Vier-Durchläufe-Propagations-Schleife legte eine zweite Schwäche nach. Payload, die über ein indirektes Array erreicht wurde oder über eine lange Kette, deren Objektnummern rückwärts laufen, bekam unter Umständen nie irgendeine Rolle

Warum muss ein Signature-Validator fragen, wem ein Objekt gehört?

Ein Signature-Validator muss fragen, wem ein Objekt gehört, weil PDF-Incremental-Updates (ISO 32000-1 §7.5.6) jedem erlauben, eine Revision anzuhängen, die eine bestehende Objektnummer neu definiert, und der neu definierte Body verrät nicht, was er ist. Die Signatur verifiziert weiterhin, sie deckt ja nur die Bytes ihrer eigenen Revision. Jede Verteidigung gegen Manipulation nach dem Signieren hängt deshalb davon ab, jedes geänderte Objekt der Struktur zuzuordnen, die es benutzt, und dann zu fragen, ob der Unterzeichner erlaubt hat, dass sich diese Struktur ändert

Mehrere veröffentlichte Angriffsklassen arbeiten exakt in dieser Lücke. Incremental-Saving-Angriffe hängen eine Revision an, die Seiteninhalt austauscht, und setzen darauf, dass der Verifier nur die signierte Byte-Range prüft. Shadow-Angriffe pflanzen versteckten Inhalt vor dem Signieren ein und aktivieren ihn danach mit einer kleinen, unverfänglichen Änderung. Angriffe auf zertifizierte Dokumente missbrauchen die Tatsache, dass P=2 und P=3 manche späteren Edits ausdrücklich erlauben, und kleiden dann einen verbotenen Edit als erlaubten. Ein Verifier, der Objekte nach Labels wie /Type /Annot klassifiziert oder nach irgendeinem Referenzpfad, der zufällig zu ihnen führt, ist der dritten Klasse ausgesetzt: Der Angreifer braucht nur eine erlaubte Struktur, die die verbotene erreichen kann

Deshalb lautet die Frage nicht, welche Objekte sich geändert haben, sondern wem sie gehören. Ein Content-Stream, den eine Seite über /Contents erreicht, ist Seiteninhalt, egal was sonst noch auf ihn zeigt. Eine Annotation, die über /P zurück auf die Seite zeigt, sagt, wo die Annotation lebt, nicht, was sie besitzt

Wie modelliert PDFium Component v3.126.2 Ownership?

PDFium Component v3.126.2 behandelt Rückverweise als Navigation, hält sie aus der Rollen-Propagation heraus und entscheidet anhand der strukturellen Rolle des besitzenden Dictionarys, welche Keys als Navigation zählen, nicht allein am Key-Namen. Die Tabelle fasst die Navigations-Keys zusammen, die keine Ownership mehr tragen

Besitzer-DictionaryAls Navigation behandelte KeysSpezifikations-Referenz
Page- oder Pages-Knoten/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Annotation oder Widget/PISO 32000-1 §12.5.2
Widget- oder Field-Dictionary/ParentISO 32000-1 §12.7.3
PDFium-Component-v3.126.2-Objektgraph, der owned-payload-Kanten wie /Contents und /Annots, die Seiten- und Annotations-Rollen ausbreiten, von Navigations-Kanten wie Widget-/P trennt, die keine Rollen tragen, mit den Navigations-Keys je Besitzer-Dictionary und prckPageContent auf dem Content-Stream selbst unter einem gefälschten /Type
v3.126.2 hält Rückverweise aus der Rollen-Propagation heraus: Rollen reisen nur über echte Ownership, der Content-Stream bleibt also Seiteninhalt, und der /P-Hinweis entscheidet nichts

Global nach Key-Namen zu filtern hätte ein neues Loch gerissen. Eine Font- oder XObject-Ressource darf legitim /P, /Parent oder /Annots heißen, und ein /Resources-Dictionary, das seinen /P-Eintrag aus der Propagation nimmt, ließe einen Angreifer ein seitenbesessenes XObject hinter einem unverfänglichen Ressourcennamen verstecken. In v3.126.2 greift der Navigationsfilter nur, wenn das besitzende Dictionary tatsächlich eine Seite, ein Pages-Knoten, eine Annotation, ein Widget oder ein Field ist. Trägt eines dieser Dictionarys einen doppelt gesetzten Navigations-Key, etwa zwei /P-Einträge in einem Widget, rät der Analyzer nicht, welche Kopie ein Viewer benutzen würde; der Rollenaufbau schlägt fehl, und die Signatur wird Indeterminate

Mehrere weitere Regeln schließen die verbleibenden Umfirmierungs-Wege:

  • Pages-Knoten sind in eigenem Recht Seiten-Rollen-Roots, Ressourcen, die aus dem Pages-Baum geerbt werden (ISO 32000-1 §7.7.3.4), gelangen also über echte Ownership in den Seiten-Kontext, nicht über einen /Parent-Spaziergang von einer Kindseite
  • Eine Annotations- oder Form-Rolle, die bei einem Catalog, Pages-Knoten, einer Seite, einer Annotation oder einem Field-Dictionary ankommt, endet dort, denn diese strukturellen Objekte begründen ihre eigenen Rollen, und eine hereinkommende Payload-Rolle darf sie nicht überstimmen
  • Die Seiten-Rolle ist bei der Klassifizierung autoritativ: Ein seitenbesessenes Objekt ist prckPageContent, selbst wenn eine spätere Revision es mit einem gefälschten /FT, einem /Type /Annot-Label umschreibt oder es mit einem Appearance-Stream teilt
  • Ein Form XObject, das nur als Field- oder Annotation-Appearance dient, behält seine Form- oder Annotations-Kategorie, die gewöhnliche Appearance-Regenerierung nach einem Form-Fill wird also weiterhin nach den normalen Erlaubnis-Regeln bewertet
  • Ein Widget ohne eigenes /FT löst den geerbten Feldtyp über die /Parent-Kette auf, und eine nicht auflösbare Kette lässt den Rollenaufbau scheitern, statt auf Annotation zu defaulten
  • Die Rollen-Bits jeder späteren Revision werden in die Rollen der abgedeckten Revision verschmolzen, ein späteres Update kann also eine frühere Seiten-Ownership-Beziehung nicht auslöschen, indem es zuerst einen Stream abkoppelt und ihn danach editiert

Fixpunkt statt fester Durchlaufanzahl

Die Rollen-Erreichbarkeit läuft in v3.126.2 als Work-Queue, die iteriert, bis kein Objekt mehr ein neues Rollen-Bit gewinnt, ein echter Fixpunkt, egal wie tief die Kette oder wie die Objekte nummeriert sind. Indirekte Arrays, etwa ein als eigenes Objekt gespeichertes /Contents-Array, werden mit abgelaufen. Jedes Objekt kann höchstens vier verschiedene Rollen-Bits gewinnen, die Queue ist also auf vier Einträge pro Objektnummer begrenzt; ein Überschreiten dieses Budgets wirft prrResourceLimitExceeded. Ein Verweis auf ein freies Objekt, ein Generation-Mismatch oder ein kaputter Objekt-Header wirft prrMalformedRevisionChain, Payload innerhalb eines komprimierten Object-Streams wirft prrCompressedObjectUnresolved. Jeder dieser Fehlschläge endet mit prasIndeterminate, nie mit einem erlaubten Urteil, und passiert der Fehlschlag beim Aufbau der Rollen der abgedeckten Revision, meldet die Signatur gar keine Changes

Die folgende Routine listet die Seiteninhalts-Edits auf, die diese Analyse überleben. Eine prckPageContent-Änderung wird nie als prdAllowed bewertet: DocMDP P=1, 2 oder 3 macht sie zu prdDisallowed, und eine Signatur ohne DocMDP bewertet sie als 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, nichts freizugeben
    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;

Was passiert, wenn sich FieldMDP und eine Annotation ein Objekt teilen?

Wenn das /V eines Felds und das /Contents einer Annotation auf dasselbe indirekte Objekt zeigen, hält v3.126.2 die FieldMDP-Sperre in Kraft, selbst wenn die Änderung als Annotations-Edit klassifiziert ist. Das Szenario lässt sich von Hand leicht bauen: Ein Unterzeichner sperrt das Total-Feld mit FieldMDP (ISO 32000-1 §12.8.2.4), und der Angreifer lässt das /Contents einer Text-Annotation dasselbe String-Objekt referenzieren, das den Feldwert hält. Unter P=3 ist der Annotations-Edit erlaubt, das Umschreiben dieses geteilten Strings änderte vor dem Fix also einen gesperrten Feldwert mit einem erlaubten Urteil

Das Objekt trägt jetzt beide Rollen, Annotation und Form, und die Annotations-Entscheidung prüft die Form-Seite nach, sobald die Signatur eine FieldMDP-Transform hat:

  • Unter P=2 ist die Annotations-Änderung rundherum unzulässig, exakt wie zuvor
  • Mit FieldMDP All ist jedes Feld gesperrt, die geteilte Änderung ist also prdDisallowed
  • Mit FieldMDP Include oder Exclude kann der Analyzer einen geteilten Skalar nicht auf einen Feldnamen zurückverfolgen, das Urteil ist also prdIndeterminate statt einer Vermutung
  • Ohne FieldMDP greift die P=3-Annotations-Regel, und die Änderung bleibt erlaubt
PDFium-Component-FieldMDP-Entscheidungsdiagramm, in dem das /V eines gesperrten Total-Felds und das /Contents einer Annotation dasselbe indirekte Objekt referenzieren, mit Verzweigung über DocMDP P=2, FieldMDP All, FieldMDP Include oder Exclude und kein FieldMDP zu den Urteilen prdDisallowed, prdIndeterminate oder prdAllowed für denselben geteilten Edit
Trägt ein indirektes Objekt beide Rollen, Annotation und Form, prüft die Annotations-Entscheidung die FieldMDP-Sperre nach, derselbe Edit reicht also von erlaubt über unzulässig bis unbestimmt

Ein Reporting-Detail zählt für Gate-Code. Der geteilte Fall wird als Kind = prckAnnotation mit Decision = prdIndeterminate gemeldet, und prrFieldMdpUnresolved kommt nur für als Formfelder klassifizierte Änderungen in die Risiko-Menge. Ein Gate, das nach prrFieldMdpUnresolved sucht und Status ignoriert, verpasst diesen Fall komplett

Wie sollte Delphi-Code bei der Revisions-Analyse fail-closed reagieren?

Delphi-Code sollte ein signiertes Dokument nur akzeptieren, wenn der Analyse-Status prasNoLaterChanges oder prasAllowed ist und kein strukturelles Risiko vorliegt, und er sollte prasIndeterminate und prasSuspicious als nicht vertrauenswürdig behandeln, nicht als Warnung, die man loggt und durchlässt. Indeterminate heißt, der Analyzer konnte nicht beweisen, dass die späteren Revisionen erlaubt waren; für einen Angreifer ist eine Eingabe, die zuverlässig Indeterminate produziert, genauso nützlich wie eine, die Allowed produziert, wenn Ihr Code sie durchlässt. Die globale AnalyzePadesSignatureRevisions nimmt ein beliebiges TStream und liest es ab Position 0, passend für Upload-Handler, die das Dokument nie rendern müssen

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Doppelte Definitionen werden erfasst, ohne Status abzustufen
  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 und prasSuspicious sind Zurückweisungen, keine Warnungen
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Zwei Grenzen sind es wert, klar benannt zu werden. TPadesRevisionAnalysisReport sagt nichts über CMS-Integrität oder Zertifikatsvertrauen, dieses Gate sitzt also neben der kryptografischen und Trust-Validierung, nicht an ihrer Stelle. Und ein korrekter Ownership-Graph macht P=3 nicht für jeden Workflow sicher. P=3 erlaubt Annotationen wirklich, und eine Annotation mit opakem Appearance kann signierten Text überdecken, ohne einen einzigen Content-Stream anzufassen. Sind Ihre zertifizierten Dokumente Verträge statt Review-Kopien, dann zertifizieren Sie entweder mit P=2 oder routen erlaubte Annotations-Änderungen an einen Menschen, wie in diesem Helfer:

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;

Checkliste für das Signature-Revisions-Audit

Nehmen Sie diese Liste, um zu prüfen, ob Ihre Verifikations-Pipeline exponiert war und ob sie jetzt fail-closed reagiert:

  • PDFium-Component-Builds vor v3.126.2 konnten für Seiteninhalts-Edits in DocMDP-P=3-Dokumenten prasAllowed melden; fahren Sie TPdf.AnalyzeSignatureRevisions über zertifizierte P=3-Dateien, die ältere Builds akzeptiert haben
  • Prüfen Sie P=3-Dokumente mit FieldMDP-Sperren erneut, wo ein Feldwert und eine Annotation sich ein indirektes Objekt teilen könnten
  • Akzeptieren Sie nur prasNoLaterChanges und prasAllowed; behandeln Sie prasIndeterminate und prasSuspicious als nicht vertrauenswürdig
  • Testen Sie Report.Risks genauso wie Report.Status, denn prrDuplicateObjectDefinition ändert den Status für sich genommen nicht
  • Lesen Sie ein leeres Changes-Array nicht als sauberes Ergebnis, wenn der Signatur-Status Indeterminate ist; ein gescheiterter Rollenaufbau meldet keine Änderungen
  • Verlassen Sie sich nicht allein auf prrFieldMdpUnresolved, um FieldMDP-Probleme zu erwischen, der geteilte Annotations-Fall zeigt sich nur über Entscheidung und Status
  • Entscheiden Sie, ob erlaubte Annotations-Änderungen unter P=3 in Ihrem Workflow eine menschliche Prüfung brauchen
  • Analysieren Sie die Original-Bytes der Datei; ein mit SaveAs umgeschriebenes Dokument enthält die Revisions-Kette nicht mehr

Revisions-Analyse ist eine Schicht einer Signaturprüfung. Kombinieren Sie sie mit der Inspektion von PDF-Digitalsignaturen und PAdES-Leveln für Dictionary und Baseline-Level, und mit einem breiteren PDF-Sicherheitsrisiko-Audit für JavaScript, Launch-Actions und eingebettete Dateien. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions und der hier beschriebene Ownership-bewusste Rollengraph kommen mit der PDFium Component für Delphi, C++Builder und Lazarus