Articol tehnic

DocMDP în PDFium Component: editări de pagină ascunse de /P

În build-urile PDFium Component de dinainte de v3.126.2, TPdf.AnalyzeSignatureRevisions putea gradua o editare reală de conținut de pagină drept o schimbare permisă de adnotare sub DocMDP P=3, pentru că graful de roluri al revision-ului trata referința inversă /P a widget-ului de semnătură spre pagina lui drept proprietate. Din v3.126.2, PDFium Component separă muchiile de navigare de muchiile de payload deținut, deci conținutul de pagină rămâne conținut de pagină. Raportul de bug din spatele reparării arată inofensiv pe hârtie. Un contract certificat permite adnotări, contra parte face o salvare incrementală, iar analizatorul spune că orice schimbare ulterioară e permisă. Apoi cineva diferențiază paginile randate și suma de plată de pe pagina 2 e alta

Articolul ăsta e continuarea din ochii atacatorului a privirii de ansamblu asupra analizei schimbărilor de revision după semnătură, deci sare peste noțiunile de bază ale reconstruirii revision-urilor și gradării DocMDP și merge drept la graful de obiecte: cum era modelată proprietatea, de ce direcția unei muchii decide un verdict de securitate, ce s-a schimbat în v3.126.2 și cum vă auditați propria logică de acceptanță

De ce a trecut o editare de pagină drept schimbare de adnotare sub DocMDP P=3?

Editarea de pagină a trecut pentru că vechiul graf de roluri urma fiecare referință indirectă dintr-un dicționar ca și cum obiectul referențiat ar fi aparținut celui care referențiază, iar widget-ul de semnătură pointează înapoi spre pagina lui. Un dicționar de adnotare poartă /P, o referință indirectă la obiectul de pagină pe care stă (ISO 32000-1 §12.5.2). Intrarea aceea e un indiciu de navigare. Widget-ul nu deține pagina; pagina îl deține pe el prin array-ul /Annots

Analizatorul atribuie fiecărui obiect un set de biți de rol înainte să gradueze schimbările ulterioare: pagină, adnotare, formă și material de validare. Obiectele rădăcină își iau rolul din propriul dicționar, iar rolul se răspândește apoi la tot ce referențiază ele. În propagarea veche, lanțul mergea așa:

  1. Widget-ul de semnătură e un /Subtype /Widget cu /FT /Sig, deci capătă rolul de adnotare
  2. /P-ul widget-ului împinge rolul de adnotare pe dicționarul de pagină, care are deja rolul de pagină
  3. Pagina împinge ambele roluri în /Contents, /Resources și, prin /Parent, sus în arborele Pages și peste fiecare pagină soră
  4. Un dicționar de content stream precum << /Length 812 >> nu are /Type, deci clasificatorul cădea pe biții de rol și verifica rolul de adnotare înaintea rolului de pagină
Diagramă PDFium Component a grafului de roluri DocMDP de dinainte de v3.126.2 în care o referință inversă /P a unui widget de semnătură împinge rolul de adnotare pe dicționarul de pagină, pagina îl răspândește prin /Contents pe un content stream fără intrare /Type, clasificatorul scoate prckAnnotation, iar gradarea P=3 întoarce prdAllowed
Înainte de v3.126.2 graful de roluri trata fiecare referință indirectă drept proprietate, deci intrarea /P a widget-ului împingea rolul de adnotare pe pagină, iar o editare autentică de pagină ieșea din analizator drept schimbare permisă de adnotare

Content stream-ul modificat ieșea prin urmare ca prckAnnotation. Sub ISO 32000-1 §12.8.2.2, DocMDP P=3 permite schimbări de adnotări, deci decizia era prdAllowed, iar raportul se rola la prasAllowed. Același fișier sub P=2 era respins, dar doar din întâmplare: P=2 interzice schimbările de adnotări, deci stream-ul etichetat greșit era refuzat din motivul greșit. O buclă de propagare cu patru treceri fixe adăuga a doua slăbiciune. Payload-ul ajuns printr-un array indirect, sau printr-un lanț lung ale cărui numere de obiecte rulează înapoi, s-ar putea să nu primească deloc rol

De ce trebuie un validator de semnături să întrebe cine deține un obiect?

Un validator de semnături trebuie să întrebe cine deține un obiect pentru că update-urile incrementale PDF (ISO 32000-1 §7.5.6) lasă pe oricine să adauge un revision care redefineste un număr de obiect existent, iar corpul redefinit nu anunță ce e. Semnătura încă verifică, fiindcă acoperă doar bytes-urile propriei revision-uri. Fiecare apărare contra manipulării după semnare depinde deci de maparea fiecărui obiect schimbat la structura care îl folosește, și apoi de întrebarea dacă semnatarul a permis schimbarea structurii aceleia

Mai multe clase de atacuri publicate lucrează exact în golul acela. Atacurile de salvare incrementală adaugă un revision care schimbă conținutul paginii și se bazează pe un verifier care verifică doar intervalul de bytes semnat. Atacurile shadow plantează conținut ascuns înainte de semnare și îl activează apoi cu o schimbare mică, cu aspect inocent. Atacurile asupra documentelor certificat abuzează de faptul că P=2 și P=3 permit în mod explicit unele editări ulterioare, apoi deghizează o editare interzisă drept una permisă. Un verifier care clasifică obiectele după etichete precum /Type /Annot, sau după orice cale de referință care se întâmplă să le ajungă, e expus la a treia clasă: atacatorul are nevoie doar de o structură permisă care poate ajunge la cea interzisă

De aceea întrebarea nu e ce obiecte s-au schimbat, ci cine le deține. Un content stream ajuns dintr-o pagină prin /Contents e conținut de pagină indiferent ce altceva pointează spre el. O adnotare care pointează înapoi spre pagină prin /P spune unde locuiește adnotarea, nu ce deține

Cum modelează PDFium Component v3.126.2 proprietatea?

PDFium Component v3.126.2 tratează referințele inverse drept navigare, le ține în afara propagării de roluri și decide ce chei contează drept navigare din rolul structural al dicționarului care le deține, nu doar din numele cheii. Tabelul rezumă cheile de navigare care nu mai poartă proprietate

Dicționar proprietarChei tratate drept navigareReferință în specificație
Nod Page sau Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Adnotare sau widget/PISO 32000-1 §12.5.2
Dicționar widget sau field/ParentISO 32000-1 §12.7.3
Graf de obiecte PDFium Component v3.126.2 care separă muchiile de payload deținut precum /Contents și /Annots, care răspândesc rolurile de pagină și de adnotare, de muchiile de navigare precum /P-ul widget-ului, care nu poartă roluri, cu cheile de navigare per dicționar proprietar și prckPageContent păstrat pe content stream chiar și sub un /Type falsificat
v3.126.2 ține referințele inverse în afara propagării de roluri: rolurile călătoresc doar prin proprietate reală, deci content stream-ul rămâne conținut de pagină, iar indiciul /P nu decide nimic

Filtrarea globală după numele cheii ar fi creat o nouă gaură. O resursă font sau XObject poate fi legitim numită /P, /Parent sau /Annots, iar un dicționar /Resources care și-ar scoate intrarea /P din propagare ar lăsa un atacator să ascundă un XObject deținut de pagină în spatele unui nume de resursă inocent. În v3.126.2 filtrul de navigare se aplică doar când dicționarul proprietar chiar e o pagină, un nod Pages, o adnotare, un widget sau un field. Dacă unul dintre dicționarele acelea poartă o cheie de navigare duplicată, precum două intrări /P într-un widget, analizatorul nu ghicește ce copie ar folosi un viewer; construcția rolurilor pice, iar semnătura devine Indeterminate

Mai multe reguli suplimentare închid rutele rămase de re-etichetare:

  • Nodurile Pages sunt rădăcini cu rol de pagină la drepturi proprii, deci resursele moștenite din arborele Pages (ISO 32000-1 §7.7.3.4) intră în contextul de pagină prin proprietate reală, nu printr-o plimbare pe /Parent de la o pagină copil
  • Un rol de adnotare sau de formă care ajunge la un catalog, un nod Pages, o pagină, o adnotare sau un dicționar de field se oprește acolo, pentru că obiectele structurale acelea își stabilesc propriile roluri, iar un rol de payload care vine nu trebuie să le treacă peste
  • Rolul de pagină e autoritar la clasificare: un obiect deținut de pagină e prckPageContent chiar dacă un revision ulterior îl rescrie cu un /FT falsificat, o etichetă /Type /Annot, sau îl partajează cu un appearance stream
  • Un Form XObject folosit doar drept aparență de field sau de adnotare își păstrează categoria de formă sau de adnotare, deci regenerarea obișnuită de aparențe după o completare de formă e tot gradată sub regulile normale de permisiune
  • Un widget fără /FT propriu rezolvă tipul de field moștenit prin lanțul /Parent, iar un lanț nerezolvabil face să pice construcția rolurilor în loc să recadă pe adnotare
  • Biții de rol din fiecare revision ulterior sunt fuzionați în rolurile revision-ului acoperit, deci un update ulterior nu poate șterge o relație anterioară de proprietate de pagină detașând întâi un stream și editându-l apoi

Punct fix în loc de un număr fix de treceri

Reachability-ul rolurilor în v3.126.2 rulează ca o coadă de lucru care iterează până când niciun obiect nu mai capătă un bit de rol nou, ceea ce e un punct fix adevărat indiferent de adâncimea lanțului sau numerotarea obiectelor. Array-urile indirecte, precum un array /Contents stocat ca obiect propriu, sunt parcurse și ele. Fiecare obiect poate capăta cel mult patru biți de rol distincți, deci coada e mărginită la patru intrări per număr de obiect; depășirea bugetului acela ridică prrResourceLimitExceeded. O referință la un obiect liber, un mismatch de generație sau un antet de obiect stricat ridică prrMalformedRevisionChain, iar payload-ul dintr-un object stream comprimat ridică prrCompressedObjectUnresolved. Fiecare dintre aceste eșecuri se termină cu prasIndeterminate, niciodată cu un verdict permis, iar când eșecul apare în timpul construirii rolurilor revision-ului acoperit, semnătura nu raportează deloc Changes

Rutina de mai jos listează editările de conținut de pagină care supraviețuiesc acestei analize. O schimbare prckPageContent nu e niciodată gradată prdAllowed: DocMDP P=1, 2 sau 3 o face prdDisallowed, iar o semnătură fără DocMDP o gradează 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, nimic de eliberat
    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;

Ce se întâmplă când FieldMDP și o adnotare împart un obiect?

Când /V-ul unui field și /Contents-ul unei adnotări pointează spre același obiect indirect, v3.126.2 păstrează lacătul FieldMDP în vigoare deși schimbarea e clasificată drept editare de adnotare. Scenariul e ușor de construit de mână: un semnatar lacătează field-ul Total cu FieldMDP (ISO 32000-1 §12.8.2.4), iar atacatorul face ca /Contents-ul unei adnotări de text să refere același obiect de șir care deține valoarea field-ului. Sub P=3 editarea de adnotare e permisă, deci înainte de reparare rescrierea șirului partajat schimba o valoare de field încuiat cu un verdict permis

Obiectul poartă acum ambele roluri, de adnotare și de formă, iar decizia de adnotare reverifică partea de formă de fiecare dată când semnătura are o transformare FieldMDP:

  • Sub P=2 schimbarea de adnotare e respinsă categoric, exact ca înainte
  • Cu FieldMDP All, fiecare field e încuiat, deci schimbarea partajată e prdDisallowed
  • Cu FieldMDP Include sau Exclude, analizatorul nu poate urmări un scalar partajat înapoi la un nume de field, deci decizia e prdIndeterminate, nu o ghicire
  • Fără FieldMDP, se aplică regula de adnotare P=3, iar schimbarea rămâne permisă
Diagramă de decizie FieldMDP PDFium Component în care /V-ul unui field Total încuiat și /Contents-ul unei adnotări referă același obiect indirect, ramificând peste DocMDP P=2, FieldMDP All, FieldMDP Include sau Exclude și lipsa FieldMDP spre verdictele prdDisallowed, prdIndeterminate sau prdAllowed pentru aceeași editare partajată
Când un obiect indirect poartă ambele roluri, de adnotare și de formă, decizia de adnotare reverifică lacătul FieldMDP, deci aceeași editare variază de la permis la respins la indeterminat

Un detaliu de raportare contează pentru codul de porți. Cazul partajat e raportat ca Kind = prckAnnotation cu Decision = prdIndeterminate, iar prrFieldMdpUnresolved e adăugat la setul de riscuri doar pentru schimbările clasificate drept form fields. O porți care caută prrFieldMdpUnresolved și ignoră Status ratează cazul acesta complet

Cum trebuie codul Delphi să eșueze închis la analiza revision-urilor?

Codul Delphi ar trebui să accepte un document semnat doar când statusul analizei e prasNoLaterChanges sau prasAllowed și niciun risc structural nu e prezent, și ar trebui să trateze prasIndeterminate și prasSuspicious drept lipsite de încredere, nu drept avertismente de logat și trecute mai departe. Indeterminate înseamnă că analizatorul nu a putut dovedi că revision-urile ulterioare erau permise; pentru un atacator, un input care produce în mod fiabil Indeterminate e la fel de folositor ca unul care produce Allowed dacă codul vostru îl lasă să treacă. Funcția globală AnalyzePadesSignatureRevisions primește orice TStream și îl citește de la poziția 0, ceea ce li se potrivește handler-ilor de upload care nu au nevoie să randeze documentul

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Definițiile duplicate sunt înregistrate fără a degrada 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 și prasSuspicious sunt respingeri, nu avertismente
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Două limite merită spuse limpede. TPadesRevisionAnalysisReport nu spune nimic despre integritatea CMS sau despre încrederea în certificate, deci poarta asta stă lângă validarea criptografică și de încredere, nu în locul lor. Iar un graf de proprietate corect nu face P=3 sigur pentru orice flux de lucru. P=3 permite cu adevărat adnotări, iar o adnotare cu aparență opacă poate acoperi text semnat fără să atingă vreun content stream. Dacă documentele voastre certificate sunt contracte, nu exemplare de revizuit, certificați fie cu P=2, fie dirijați schimbările permise de adnotări spre un om, ca în helper-ul acesta:

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;

Listă de verificare pentru auditul revision-urilor de semnătură

Folosiți lista asta ca să verificați dacă pipeline-ul vostru de verificare a fost expus și dacă acum eșuează închis:

  • Build-urile PDFium Component de dinainte de v3.126.2 puteau raporta prasAllowed pentru editări de conținut de pagină în documente DocMDP P=3; rerulați TPdf.AnalyzeSignatureRevisions pe fișierele P=3 certificate acceptate de build-urile vechi
  • Reverificați documentele P=3 cu lacăte FieldMDP acolo unde o valoare de field și o adnotare ar putea împărtăși un obiect indirect
  • Acceptați doar prasNoLaterChanges și prasAllowed; tratați prasIndeterminate și prasSuspicious drept lipsite de încredere
  • Testați și Report.Risks, nu doar Report.Status, fiindcă prrDuplicateObjectDefinition nu schimbă statusul de unul singur
  • Nu citiți un array Changes gol drept rezultat curat când statusul semnăturii e Indeterminate; o construcție de roluri eșuată nu raportează nicio schimbare
  • Nu vă bazați doar pe prrFieldMdpUnresolved ca să prindeți probleme FieldMDP, fiindcă cazul de adnotare partajată iese la iveală doar prin decizie și status
  • Decideți dacă schimbările permise de adnotări sub P=3 au nevoie de revizuire umană în fluxul vostru de lucru
  • Analizați bytes originali ai fișierului; un document rescris de SaveAs nu mai conține lanțul de revision-uri

Analiza revision-urilor e un singur strat al unei verificări de semnătură. Împerecheați-o cu inspectarea semnăturilor digitale PDF și a nivelurilor PAdES pentru dicționar și nivelul de bază, și cu un audit mai larg al riscurilor de securitate PDF pentru JavaScript, acțiuni de lansare și fișiere embedded. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions și graful de roluri conștient de proprietate descris aici sosesc în PDFium Component pentru Delphi, C++Builder și Lazarus