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 :
- Le widget de signature est un
/Subtype /Widgetavec/FT /Sig, donc il reçoit le rôle annotation - Le
/Pdu widget pousse le rôle annotation sur le dictionnaire de page, qui a déjà le rôle page - La page pousse les deux rôles dans
/Contents,/Resourceset, via/Parent, remonte dans l’arbre Pages et se répand vers chaque page sœur - 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
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étaire | Clés traitées comme navigation | Référence de spéc |
|---|---|---|
| Nœud Page ou Pages | /Parent, /Kids, /Annots | ISO 32000-1 §7.7.3 |
| Annotation ou widget | /P | ISO 32000-1 §12.5.2 |
| Dictionnaire widget ou champ | /Parent | ISO 32000-1 §12.7.3 |
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
/Parentdepuis 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
prckPageContentmême si une révision ultérieure le réécrit avec un/FTfalsifié, 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
/FTré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é estprdDisallowed - Avec FieldMDP
IncludeouExclude, l’analyseur ne peut pas retracer un scalaire partagé jusqu’à un nom de champ, donc la décision estprdIndeterminateplutôt qu’une devinette - Sans FieldMDP, la règle d’annotation P=3 s’applique et le changement reste permis
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
prasAllowedpour des éditions de contenu de page dans des documents DocMDP P=3 ; relancezTPdf.AnalyzeSignatureRevisionssur 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
prasNoLaterChangesetprasAllowed; traitezprasIndeterminateetprasSuspiciouscomme non fiables - Testez
Report.Risksautant queReport.Status, parce queprrDuplicateObjectDefinitionne change pas le statut à lui seul - Ne lisez pas un tableau
Changesvide 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
prrFieldMdpUnresolvedpour 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
SaveAsne 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