Article technique

DocMDP PDFium : un /P de widget cachait les éditions de page

Dans les builds de PDFium Component d’avant v3.126.2, TPdf.AnalyzeSignatureRevisions pouvait classer une vraie édition de contenu de page comme un changement d’annotation permis sous DocMDP P=3, parce que son graphe de rôles de révisions traitait la référence inverse /P du widget de signature vers sa page comme de la propriété. Depuis v3.126.2, PDFium Component sépare les arêtes de navigation des arêtes de charge possédée, donc le contenu de page reste du contenu de page. Le rapport de bug derrière ce correctif a l’air inoffensif sur le papier. Un contrat certifié permet les annotations, une contrepartie ajoute une sauvegarde incrémentale, et l’analyseur dit que chaque changement ultérieur est permis. Puis quelqu’un compare les pages rendues et le montant du paiement en page 2 est différent

Cet article est la suite vue de l’attaquant du panorama de l’analyse des changements de révisions post-signature, donc il saute les bases de la reconstruction des révisions et de la notation DocMDP et file droit au graphe d’objets : comment la propriété était modélisée, pourquoi la direction d’une arête décide d’un verdict de sécurité, ce qui a changé en v3.126.2, et comment auditer votre propre logique d’acceptation

Pourquoi une édition de page passait-elle pour un changement d’annotation sous DocMDP P=3 ?

L’édition de page passait parce que l’ancien graphe de rôles suivait chaque référence indirecte d’un dictionnaire comme si l’objet référencé appartenait à celui qui le référence, et le widget de signature pointe en retour vers sa page. Un dictionnaire d’annotation porte /P, une référence indirecte vers l’objet page sur lequel il se pose (ISO 32000-1 §12.5.2). Cette entrée est un indice de navigation. Le widget ne possède pas la page ; la page possède le widget via son tableau /Annots

L’analyseur attribue à chaque objet un jeu de bits de rôle avant de noter les changements ultérieurs : page, annotation, formulaire et matériel de validation. Les objets racine tirent leur rôle de leur propre dictionnaire, et le rôle se propage ensuite à tout ce qu’ils référencent. Dans l’ancienne propagation, la chaîne se déroulait ainsi :

  1. Le widget de signature est un /Subtype /Widget avec /FT /Sig, donc il reçoit le rôle annotation
  2. Le /P du widget pousse le rôle annotation sur le dictionnaire de page, qui a déjà le rôle page
  3. La page pousse les deux rôles dans /Contents, /Resources et, via /Parent, remonte dans l’arbre Pages et se répand vers chaque page sœur
  4. Un dictionnaire de flux de contenu tel que << /Length 812 >> n’a pas de /Type, donc le classifieur retombait sur les bits de rôle et vérifiait le rôle annotation avant le rôle page
Schéma PDFium Component du graphe de rôles DocMDP d’avant v3.126.2 où une référence inverse /P de widget de signature pousse le rôle annotation sur le dictionnaire de page, la page le propage via /Contents vers un flux de contenu sans entrée /Type, le classifieur sort prckAnnotation et la notation P=3 renvoie prdAllowed
Avant v3.126.2, le graphe de rôles traitait chaque référence indirecte comme de la propriété, donc l’entrée /P du widget poussait le rôle annotation sur la page et une vraie édition de page sortait de l’analyseur comme un changement d’annotation permis

Le flux de contenu modifié ressortait donc comme prckAnnotation. Sous ISO 32000-1 §12.8.2.2, DocMDP P=3 permet les changements d’annotations, donc la décision était prdAllowed et le rapport remontait en prasAllowed. Le même fichier sous P=2 était rejeté, mais seulement par coïncidence : P=2 interdit les changements d’annotations, donc le flux mal étiqueté était refusé pour la mauvaise raison. Une boucle de propagation fixée à quatre passes ajoutait une seconde faiblesse. Une charge atteinte par un tableau indirect, ou par une longue chaîne dont les numéros d’objets défilent à rebours, pouvait ne jamais recevoir aucun rôle du tout

Pourquoi un validateur de signature doit-il demander qui possède un objet ?

Un validateur de signature doit demander qui possède un objet parce que les mises à jour incrémentales du PDF (ISO 32000-1 §7.5.6) permettent à quiconque d’ajouter une révision qui redéfinit un numéro d’objet existant, et le corps redéfini n’annonce pas ce qu’il est. La signature se vérifie toujours, puisqu’elle ne couvre que les octets de sa propre révision. Toute défense contre la manipulation post-signature dépend donc de la mise en correspondance de chaque objet changé avec la structure qui l’utilise, puis de la question de savoir si le signataire a permis à cette structure de changer

Plusieurs classes d’attaques publiées opèrent exactement dans cet interstice. Les attaques par sauvegarde incrémentale ajoutent une révision qui remplace le contenu de page et comptent sur un vérificateur qui ne contrôle que la plage d’octets signée. Les shadow attacks plantent du contenu caché avant la signature et l’activent ensuite avec un petit changement à l’air innocent. Les attaques sur documents certifiés abusent du fait que P=2 et P=3 permettent explicitement certaines éditions ultérieures, puis déguisent une édition interdite en édition permise. Un vérificateur qui classe les objets par étiquettes telles que /Type /Annot, ou par n’importe quel chemin de référence qui tombe dessus, est exposé à la troisième classe : il suffit à l’attaquant d’une structure permise qui puisse atteindre la structure interdite

Voilà pourquoi la question n’est pas de savoir quels objets ont changé mais qui les possède. Un flux de contenu atteint depuis une page via /Contents est du contenu de page, quoi que d’autre pointe vers lui. Une annotation qui pointe en retour vers la page via /P dit où l’annotation réside, pas ce qu’elle possède

Comment PDFium Component v3.126.2 modélise-t-il la propriété ?

PDFium Component v3.126.2 traite les références inverses comme de la navigation, les tient hors de la propagation des rôles, et décide quelles clés comptent comme navigation d’après le rôle structurel du dictionnaire qui les porte, pas d’après le seul nom de clé. Le tableau résume les clés de navigation qui ne portent plus de propriété

Dictionnaire propriétaireClés traitées comme navigationRéférence de spéc
Nœud Page ou Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Annotation ou widget/PISO 32000-1 §12.5.2
Dictionnaire widget ou champ/ParentISO 32000-1 §12.7.3
Graphe d’objets PDFium Component v3.126.2 séparant les arêtes de charge possédée comme /Contents et /Annots, qui propagent les rôles page et annotation, des arêtes de navigation comme le /P de widget, qui ne portent aucun rôle, avec les clés de navigation par dictionnaire propriétaire et prckPageContent maintenu sur le flux de contenu même sous un /Type falsifié
v3.126.2 tient les références inverses hors de la propagation des rôles : les rôles ne voyagent que par la vraie propriété, donc le flux de contenu reste du contenu de page et l’indice /P ne décide de rien

Filtrer par nom de clé globalement aurait créé un nouveau trou. Une ressource police ou XObject peut légitimement s’appeler /P, /Parent ou /Annots, et un dictionnaire /Resources qui retirerait son entrée /P de la propagation laisserait un attaquant cacher un XObject possédé par la page derrière un nom de ressource innocent. En v3.126.2, le filtre de navigation ne s’applique que quand le dictionnaire propriétaire est réellement une page, un nœud Pages, une annotation, un widget ou un champ. Si l’un de ces dictionnaires porte une clé de navigation dupliquée, telle que deux entrées /P dans un widget, l’analyseur ne devine pas quelle copie un visualiseur utiliserait ; la construction des rôles échoue et la signature devient Indeterminate

Plusieurs règles supplémentaires ferment les routes de réétiquetage restantes :

  • Les nœuds Pages sont des racines de rôle page à part entière, donc les ressources héritées de l’arbre Pages (ISO 32000-1 §7.7.3.4) entrent en contexte page par la vraie propriété, pas par une remontée /Parent depuis une page fille
  • Un rôle annotation ou formulaire qui arrive sur un dictionnaire catalog, nœud Pages, page, annotation ou champ s’y arrête, parce que ces objets structurels établissent leurs propres rôles et qu’un rôle de charge entrant ne doit pas les écraser
  • Le rôle page fait autorité pendant la classification : un objet possédé par la page est prckPageContent même si une révision ultérieure le réécrit avec un /FT falsifié, une étiquette /Type /Annot, ou le partage avec un flux d’apparence
  • Un Form XObject utilisé seulement comme apparence de champ ou d’annotation garde sa catégorie formulaire ou annotation, donc la régénération ordinaire d’apparence après un remplissage de formulaire reste notée sous les règles de permission normales
  • Un widget sans son propre /FT résout le type de champ hérité par la chaîne /Parent, et une chaîne insoluble fait échouer la construction des rôles au lieu de retomber sur annotation
  • Les bits de rôle de chaque révision ultérieure sont fusionnés dans les rôles de la révision couverte, donc une mise à jour postérieure ne peut pas effacer une relation de propriété page antérieure en détachant d’abord un flux puis en l’éditant ensuite

Point fixe au lieu d’un nombre de passes fixé

L’atteignabilité des rôles en v3.126.2 tourne comme une file de travail qui itère jusqu’à ce qu’aucun objet ne gagne de nouveau bit de rôle, ce qui est un vrai point fixe quelle que soit la profondeur de chaîne ou la numérotation des objets. Les tableaux indirects, comme un tableau /Contents stocké comme son propre objet, sont parcourus eux aussi. Chaque objet peut gagner au plus quatre bits de rôle distincts, donc la file est bornée à quatre entrées par numéro d’objet ; dépasser ce budget lève prrResourceLimitExceeded. Une référence vers un objet libre, un décalage de génération ou un en-tête d’objet cassé lève prrMalformedRevisionChain, et une charge à l’intérieur d’un flux d’objets compressé lève prrCompressedObjectUnresolved. Chacun de ces échecs se termine en prasIndeterminate, jamais en verdict permis, et quand l’échec survient pendant la construction des rôles de la révision couverte, la signature ne rapporte aucun Changes du tout

La routine suivante liste les éditions de contenu de page qui survivent à cette analyse. Un changement prckPageContent n’est jamais noté prdAllowed : DocMDP P=1, 2 ou 3 le rend prdDisallowed, et une signature sans DocMDP le note 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;   // enregistrement, rien à libérer
    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;

Que se passe-t-il quand FieldMDP et une annotation partagent un objet ?

Quand le /V d’un champ et le /Contents d’une annotation pointent vers le même objet indirect, v3.126.2 garde le verrou FieldMDP en vigueur même si le changement est classé comme édition d’annotation. Le scénario est facile à construire à la main : un signataire verrouille le champ Total avec FieldMDP (ISO 32000-1 §12.8.2.4), et l’attaquant fait référencer par le /Contents d’une annotation texte le même objet chaîne qui porte la valeur du champ. Sous P=3, l’édition d’annotation est permise, donc avant le correctif, réécrire cette chaîne partagée changeait une valeur de champ verrouillée avec un verdict permis

L’objet porte désormais à la fois les rôles annotation et formulaire, et la décision d’annotation revérifie le côté formulaire chaque fois que la signature a une transformation FieldMDP :

  • Sous P=2, le changement d’annotation est interdit d’emblée, exactement comme avant
  • Avec FieldMDP All, chaque champ est verrouillé, donc le changement partagé est prdDisallowed
  • Avec FieldMDP Include ou Exclude, l’analyseur ne peut pas retracer un scalaire partagé jusqu’à un nom de champ, donc la décision est prdIndeterminate plutôt qu’une devinette
  • Sans FieldMDP, la règle d’annotation P=3 s’applique et le changement reste permis
Schéma de décision FieldMDP PDFium Component où le /V verrouillé d’un champ Total et le /Contents d’une annotation référencent le même objet indirect, avec branchements sur DocMDP P=2, FieldMDP All, FieldMDP Include ou Exclude et absence de FieldMDP vers les verdicts prdDisallowed, prdIndeterminate ou prdAllowed pour la même édition partagée
Quand un objet indirect porte à la fois les rôles annotation et formulaire, la décision d’annotation revérifie le verrou FieldMDP, donc la même édition va de permis à interdit à indéterminé

Un détail de compte rendu importe pour le code de barrière. Le cas partagé est rapporté comme Kind = prckAnnotation avec Decision = prdIndeterminate, et prrFieldMdpUnresolved n’est ajouté au jeu de risques que pour les changements classés comme champs de formulaire. Une barrière qui cherche prrFieldMdpUnresolved en ignorant Status rate ce cas complètement

Comment le code Delphi doit-il échouer fermé sur l’analyse de révisions ?

Le code Delphi ne devrait accepter un document signé que quand le statut d’analyse est prasNoLaterChanges ou prasAllowed et qu’aucun risque structurel n’est présent, et il devrait traiter prasIndeterminate et prasSuspicious comme non fiables, pas comme des avertissements à journaliser puis laisser passer. Indeterminate signifie que l’analyseur n’a pas pu prouver que les révisions ultérieures étaient permises ; pour un attaquant, une entrée qui produit de façon fiable Indeterminate est aussi utile qu’une qui produit Allowed si votre code la laisse passer. La globale AnalyzePadesSignatureRevisions prend n’importe quel TStream et le lit depuis la position 0, ce qui convient aux handlers d’upload qui n’ont jamais besoin de rendre le document

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Les définitions dupliquées sont enregistrées sans dégrader 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 et prasSuspicious sont des rejets, pas des avertissements
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Deux limites méritent d’être dites franchement. TPadesRevisionAnalysisReport ne dit rien de l’intégrité CMS ni de la confiance dans les certificats, donc cette barrière siège à côté de la validation cryptographique et de confiance, pas à leur place. Et un graphe de propriété correct ne rend pas P=3 sûr pour chaque flux de travail. P=3 permet réellement les annotations, et une annotation à apparence opaque peut couvrir du texte signé sans toucher le moindre flux de contenu. Si vos documents certifiés sont des contrats plutôt que des copies de révision, certifiez soit en P=2, soit routez les changements d’annotation permis vers un humain, comme dans cette aide :

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;

Checklist d’audit des révisions de signature

Servez-vous de cette liste pour vérifier si votre pipeline de vérification était exposé et s’il échoue maintenant fermé :

  • Les builds de PDFium Component d’avant v3.126.2 pouvaient rapporter prasAllowed pour des éditions de contenu de page dans des documents DocMDP P=3 ; relancez TPdf.AnalyzeSignatureRevisions sur les fichiers P=3 certifiés acceptés par des builds plus anciens
  • Revérifiez les documents P=3 avec verrous FieldMDP où une valeur de champ et une annotation pourraient partager un objet indirect
  • N’acceptez que prasNoLaterChanges et prasAllowed ; traitez prasIndeterminate et prasSuspicious comme non fiables
  • Testez Report.Risks autant que Report.Status, parce que prrDuplicateObjectDefinition ne change pas le statut à lui seul
  • Ne lisez pas un tableau Changes vide comme un résultat propre quand le statut de la signature est Indeterminate ; une construction de rôles échouée ne rapporte aucun changement
  • Ne comptez pas sur le seul prrFieldMdpUnresolved pour attraper les problèmes FieldMDP, puisque le cas d’annotation partagée ne se manifeste que par la décision et le statut
  • Décidez si les changements d’annotation permis sous P=3 exigent une revue humaine dans votre flux de travail
  • Analysez les octets du fichier d’origine ; un document réécrit par SaveAs ne contient plus la chaîne de révisions

L’analyse de révisions est une couche d’une vérification de signature. Associez-la à l’inspection des signatures numériques PDF et des niveaux PAdES pour le dictionnaire et le niveau baseline, et à un audit plus large des risques de sécurité PDF pour le JavaScript, les actions de lancement et les fichiers embarqués. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions et le graphe de rôles conscient de la propriété décrit ici sont livrés dans le PDFium Component pour Delphi, C++Builder et Lazarus