Artykuł techniczny

PDFium Component DocMDP: jak widget /P ukrywał edycje stron

W buildach PDFium Component sprzed v3.126.2 TPdf.AnalyzeSignatureRevisions potrafił ocenić prawdziwą edycję treści strony jako dozwoloną zmianę adnotacji przy DocMDP P=3, bo jego graf ról rewizji traktował wsteczną referencję /P widgeta podpisu do jego strony jako własność. Od v3.126.2 PDFium Component rozdziela krawędzie nawigacyjne od krawędzi własności z ładunkiem, więc treść strony pozostaje treścią strony. Bugreport stojący za tą poprawką wygląda na papierze niewinnie. Certyfikowana umowa dopuszcza adnotacje, druga strona dokłada jeden zapis przyrostowy, a analizator twierdzi, że każda późniejsza zmiana jest dozwolona. Potem ktoś porównuje wyrenderowane strony i kwota płatności na stronie 2 jest inna

Ten artykuł to sequela z perspektywy atakującego dla przeglądu analizy zmian rewizji po podpisie, więc pomija podstawy przebudowy rewizji i oceniania DocMDP, a idzie prosto do grafu obiektów: jak modelowano własność, dlaczego kierunek krawędzi rozstrzyga werdykt bezpieczeństwa, co się zmieniło w v3.126.2 i jak poddać audytowi własną logikę akceptacji

Dlaczego edycja strony przeszła jako zmiana adnotacji przy DocMDP P=3?

Edycja strony przeszła, bo stary graf ról szedł za każdą referencją pośrednią w słowniku tak, jakby wskazywany obiekt należał do wskazującego, a widget podpisu wskazuje z powrotem na swoją stronę. Słownik adnotacji niesie /P, pośrednią referencję do obiektu strony, na której leży (ISO 32000-1 §12.5.2). Ten wpis to podpowiedź nawigacyjna. Widget nie posiada strony; strona posiada widget przez swoją tablicę /Annots

Analizator nadaje każdemu obiektowi zbiór bitów ról, zanim oceni późniejsze zmiany: strona, adnotacja, formularz i materiał walidacyjny. Obiekty korzeniowe biorą rolę z własnego słownika, a rola rozlewa się potem na wszystko, co wskazują. W starej propagacji łańcuch biegł tak:

  1. widget podpisu to /Subtype /Widget z /FT /Sig, więc dostaje rolę adnotacji
  2. /P widgeta wpycha rolę adnotacji na słownik strony, który już ma rolę strony
  3. strona wpycha obie role do /Contents, /Resources i, przez /Parent, w górę drzewa Pages i w poprzek do każdej strony siostrzanej
  4. słownik strumienia treści jak << /Length 812 >> nie ma /Type, więc klasyfikator schodził do bitów ról i sprawdzał rolę adnotacji przed rolą strony
Diagram PDFium Component grafu ról DocMDP sprzed v3.126.2, gdzie wsteczna referencja /P widgeta podpisu wpycha rolę adnotacji na słownik strony, strona rozlewa ją przez /Contents na strumień treści bez wpisu /Type, klasyfikator wypuszcza prckAnnotation, a ocenianie P=3 zwraca prdAllowed
przed v3.126.2 graf ról traktował każdą referencję pośrednią jako własność, więc wpis /P widgeta wpychał rolę adnotacji na stronę, a prawdziwa edycja strony wychodziła z analizatora jako dozwolona zmiana adnotacji

Zmodyfikowany strumień treści wychodził więc jako prckAnnotation. Zgodnie z ISO 32000-1 §12.8.2.2 DocMDP P=3 dopuszcza zmiany adnotacji, więc decyzją było prdAllowed, a raport zwinął się do prasAllowed. Ten sam plik przy P=2 był odrzucany, ale wyłącznie przez przypadek: P=2 zabrania zmian adnotacji, więc źle oznaczony strumień był odrzucany z powodu niewłaściwego. Stała pętla propagacji o czterech przebiegach dokładała drugą słabość. Żaden ładunek dotarty przez tablicę pośrednią albo przez długi łańcuch o numerach obiektów biegnących wstecz mógł w ogóle nie dostać żadnej roli

Dlaczego walidator podpisu musi pytać, kto posiada obiekt?

Walidator podpisu musi pytać, kto posiada obiekt, bo aktualizacje przyrostowe PDF (ISO 32000-1 §7.5.6) pozwalają każdemu dokleić rewizję przedefiniowującą istniejący numer obiektu, a przedefiniowane ciało nie oznajmia, czym jest. Podpis wciąż się weryfikuje, bo obejmuje wyłącznie bajty własnej rewizji. Każda obrona przed manipulacją po podpisie zależy więc od mapowania każdego zmienionego obiektu na strukturę, która go używa, i od pytania, czy podpisujący pozwolił tej strukturze się zmienić

Kilka opublikowanych klas ataków działa dokładnie w tej luce. Incremental saving attacks doklejają rewizję podmieniającą treść strony i liczą na walidatora sprawdzającego tylko podpisany zakres bajtów. Shadow attacks zasadzają ukrytą treść przed podpisaniem i aktywują ją po fakcie małą, niewinnie wyglądającą zmianą. Ataki na dokumenty certyfikowane nadużywają faktu, że P=2 i P=3 wprost dopuszczają niektóre późniejsze edycje, po czym przebierają zakazaną edycję za dozwoloną. Walidator klasyfikujący obiekty po etykietach takich jak /Type /Annot albo po dowolnej ścieżce referencji, która akurat do nich dociera, jest wystawiony na trzecią klasę: atakującemu wystarczy jedna dozwolona struktura, która sięga tamtej zakazanej

Dlatego pytanie brzmi nie „które obiekty się zmieniły”, tylko „kto je posiada”. Strumień treści osiągnięty ze strony przez /Contents jest treścią strony, cokolwiek innego by na niego nie wskazywało. Adnotacja wskazująca z powrotem na stronę przez /P mówi, gdzie adnotacja mieszka, a nie co posiada

Jak PDFium Component v3.126.2 modeluje własność?

PDFium Component v3.126.2 traktuje referencje wsteczne jako nawigację, trzyma je poza propagacją ról i rozstrzyga, które klucze liczą się jako nawigacja, na podstawie strukturalnej roli słownika, który je trzyma — nie samej nazwy klucza. Tabela podsumowuje klucze nawigacyjne, które już nie niosą własności

Słownik właścicielaKlucze traktowane jako nawigacjaOdniesienie w specyfikacji
Węzeł Page albo Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
Adnotacja albo widget/PISO 32000-1 §12.5.2
Słownik widgeta albo pola/ParentISO 32000-1 §12.7.3
Graf obiektów PDFium Component v3.126.2 rozdzielający krawędzie własności z ładunkiem, jak /Contents i /Annots, które rozlewają role strony i adnotacji, od krawędzi nawigacyjnych, jak widget /P, które nie niosą żadnych ról, z kluczami nawigacyjnymi per słownik właściciela i z prckPageContent utrzymanym na strumieniu treści nawet przy podrobionym /Type
v3.126.2 trzyma referencje wsteczne poza propagacją ról: role podróżują wyłącznie przez prawdziwą własność, więc strumień treści pozostaje treścią strony, a podpowiedź /P niczego nie rozstrzyga

Filtrowanie po nazwie klucza globalnie stworzyłoby nową dziurę. Zasób czcionki albo XObject może legalnie nazywać się /P, /Parent albo /Annots, a słownik /Resources, który wyrzuciłby swój wpis /P z propagacji, pozwoliłby atakującemu schować XObject należący do strony za niewinną nazwą zasobu. W v3.126.2 filtr nawigacyjny działa tylko wtedy, gdy posiadający słownik faktycznie jest stroną, węzłem Pages, adnotacją, widgetem albo polem. Jeśli któryś z tych słowników niesie zduplikowany klucz nawigacyjny, jak dwa /P w jednym widgetcie, analizator nie zgaduje, której kopii użyłby viewer; budowanie ról kończy się porażką, a podpis staje się Indeterminate

Kilka dalszych reguł zamyka pozostałe trasy przemianowywania etykiet:

  • węzły Pages są korzeniami roli strony we własnym prawie, więc zasoby dziedziczone z drzewa Pages (ISO 32000-1 §7.7.3.4) wchodzą do kontekstu strony przez prawdziwą własność, a nie przez spacer po /Parent od strony potomnej
  • rola adnotacji albo formularza, która dociera do katalogu, węzła Pages, strony, adnotacji albo słownika pola, zatrzymuje się tam, bo te strukturalne obiekty ustanawiają własne role, a przychodzącej roli ładunku nie wolno ich nadpisać
  • rola strony jest rozstrzygająca przy klasyfikacji: obiekt posiadany przez stronę to prckPageContent nawet wtedy, gdy późniejsza rewizja przepisze go z podrobionym /FT, etykietą /Type /Annot albo udostępni go ze strumieniem wyglądu
  • Form XObject używany wyłącznie jako wygląd pola albo adnotacji zachowuje kategorię formularza albo adnotacji, więc zwyczajna regeneracja wyglądu po wypełnieniu formularza jest nadal oceniana według zwykłych reguł zezwoleń
  • widget bez własnego /FT rozwiązuje odziedziczony typ pola przez łańcuch /Parent, a nierozwiązywalny łańcuch wywala budowę ról, zamiast domyślać się adnotacji
  • bity ról z każdej późniejszej rewizji są scalane z rolami rewizji objętej podpisem, więc późniejsza aktualizacja nie może wymazać wcześniejszej relacji własności strony przez odpięcie strumienia najpierw i edycję go potem

Punkt stały zamiast stałej liczby przebiegów

Osiągalność ról w v3.126.2 biegnie jako kolejka robocza iterująca, dopóki żaden obiekt nie zyska nowego bitu roli, czyli prawdziwy punkt stały, niezależnie od głębokości łańcucha i numeracji obiektów. Tablice pośrednie, jak tablica /Contents zapisana jako osobny obiekt, też są chodzone. Każdy obiekt może zyskać najwyżej cztery odrębne bity ról, więc kolejka jest ograniczona do czterech wpisów na numer obiektu; przekroczenie tego budżetu podnosi prrResourceLimitExceeded. Referencja do obiektu wolnego, niedopasowanie generacji albo zepsuty nagłówek obiektu podnoszą prrMalformedRevisionChain, a ładunek w środku skompresowanego strumienia obiektów podnosi prrCompressedObjectUnresolved. Każda z tych porażek kończy się prasIndeterminate, nigdy werdyktem dozwolonym, a gdy porażka wydarzy się przy budowaniu ról rewizji objętej podpisem, podpis w ogóle nie raportuje żadnych Changes

Poniższa procedura wypisuje edycje treści stron, które przeżywają tę analizę. Zmiana prckPageContent nigdy nie jest oceniana jako prdAllowed: DocMDP P=1, 2 albo 3 czyni ją prdDisallowed, a podpis bez DocMDP ocenia ją jako 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;   // rekord, nic do zwolnienia
    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;

Co się dzieje, gdy FieldMDP i adnotacja dzielą jeden obiekt?

Gdy /V pola i /Contents adnotacji wskazują ten sam obiekt pośredni, v3.126.2 trzyma blokadę FieldMDP w mocy, choć zmiana jest klasyfikowana jako edycja adnotacji. Scenariusz łatwo zbudować ręcznie: podpisujący blokuje pole Total przez FieldMDP (ISO 32000-1 §12.8.2.4), a atakujący sprawia, że /Contents adnotacji tekstowej referuje ten sam obiekt tekstowy, który trzyma wartość pola. Przy P=3 edycja adnotacji jest dozwolona, więc przed poprawką przepisanie tej współdzielonej wartości tekstowej zmieniało zablokowane pole z werdyktem dozwolonym

Obiekt niesie teraz zarówno rolę adnotacji, jak i formularza, a decyzja o adnotacji ponownie sprawdza stronę formularza, ilekroć podpis ma transformację FieldMDP:

  • przy P=2 zmiana adnotacji jest z góry niedozwolona, dokładnie jak wcześniej
  • przy FieldMDP All każde pole jest zablokowane, więc współdzielona zmiana to prdDisallowed
  • przy FieldMDP Include albo Exclude analizator nie umie wyśledzić współdzielonego skalara do jednej nazwy pola, więc decyzją jest prdIndeterminate, a nie zgadywanka
  • bez FieldMDP obowiązuje reguła adnotacji P=3 i zmiana pozostaje dozwolona
Diagram decyzji FieldMDP w PDFium Component, gdzie zablokowane pole Total /V i adnotacja /Contents referują ten sam obiekt pośredni, rozgałęziający się po DocMDP P=2, FieldMDP All, FieldMDP Include albo Exclude i braku FieldMDP na werdykty prdDisallowed, prdIndeterminate albo prdAllowed dla tej samej współdzielonej edycji
gdy jeden obiekt pośredni niesie i rolę adnotacji, i formularza, decyzja o adnotacji ponownie sprawdza blokadę FieldMDP, więc ta sama edycja waha się od dozwolonej przez niedozwoloną po indeterminate

Jeden szczegół raportowania ma znaczenie dla kodu bramek. Przypadek współdzielony jest raportowany jako Kind = prckAnnotation z Decision = prdIndeterminate, a prrFieldMdpUnresolved trafia do zestawu ryzyk wyłącznie dla zmian sklasyfikowanych jako pola formularza. Bramka szukająca prrFieldMdpUnresolved i ignorująca Status gubi ten przypadek całkowicie

Jak kod w Delphi powinien stosować fail closed przy analizie rewizji?

Kod w Delphi powinien akceptować podpisany dokument tylko wtedy, gdy status analizy to prasNoLaterChanges albo prasAllowed i nie ma żadnego strukturalnego ryzyka, a prasIndeterminate i prasSuspicious traktować jako niezaufane, a nie jako ostrzeżenia do zalogowania i przepuszczenia. Indeterminate znaczy, że analizator nie zdołał udowodnić, iż późniejsze rewizje były dozwolone; dla atakującego wejście, które niezawodnie daje Indeterminate, jest równie użyteczne jak takie, które daje Allowed, jeśli twój kod je przepuści. Globalny AnalyzePadesSignatureRevisions bierze dowolny TStream i czyta go od pozycji 0, co pasuje handlerom uploadu, które nie muszą renderować dokumentu

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // Zduplikowane definicje są rejestrowane bez degradacji 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 to odrzucenia, nie ostrzeżenia
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

Dwie granice warto wypowiedzieć wprost. TPadesRevisionAnalysisReport nie mówi niczego o integralności CMS ani o zaufaniu do certyfikatów, więc ta bramka siedzi obok walidacji kryptograficznej i zaufania, a nie w ich miejscu. I poprawny graf własności nie czyni P=3 bezpiecznym dla każdego przepływu. P=3 szczerze dopuszcza adnotacje, a adnotacja z nieprzejrzystym wyglądem może zakryć podpisany tekst, nie dotykając żadnego strumienia treści. Jeśli twoje certyfikowane dokumenty to umowy, a nie kopie do recenzji, certyfikuj z P=2 albo kieruj dozwolone zmiany adnotacji do człowieka, jak w tym pomocniku:

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;

Lista kontrolna audytu rewizji podpisów

Użyj tej listy, żeby sprawdzić, czy twój potok weryfikacji był wystawiony i czy teraz zawodzi zamknięty:

  • buildy PDFium Component sprzed v3.126.2 potrafiły raportować prasAllowed dla edycji treści stron w dokumentach DocMDP P=3; przepuść ponownie TPdf.AnalyzeSignatureRevisions po certyfikowanych plikach P=3 zaakceptowanych przez starsze buildy
  • sprawdź ponownie dokumenty P=3 z blokadami FieldMDP tam, gdzie wartość pola i adnotacja mogą dzielić obiekt pośredni
  • akceptuj wyłącznie prasNoLaterChanges i prasAllowed; traktuj prasIndeterminate i prasSuspicious jako niezaufane
  • testuj Report.Risks obok Report.Status, bo prrDuplicateObjectDefinition samo w sobie nie zmienia statusu
  • nie czytaj pustej tablicy Changes jako czystego wyniku, gdy status podpisu to Indeterminate; nieudana budowa ról nie raportuje żadnych zmian
  • nie polegaj na samym prrFieldMdpUnresolved w wyłapywaniu problemów FieldMDP, bo współdzielony przypadek adnotacji wychodzi na jaw tylko przez decyzję i status
  • zdecyduj, czy dozwolone zmiany adnotacji przy P=3 potrzebują przeglądu człowieka w twoim przepływie
  • analizuj oryginalne bajty pliku; dokument przepisany przez SaveAs nie zawiera już łańcucha rewizji

Analiza rewizji to jedna warstwa sprawdzania podpisu. Sparuj ją z inspekcją cyfrowych podpisów PDF i poziomów PAdES dla słownika i poziomu bazowego oraz z szerszym audytem ryzyk bezpieczeństwa PDF dla JavaScriptu, akcji uruchamiania i plików osadzonych. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions i graf ról świadomy własności opisany tutaj jedzie w pakiecie z PDFium Component dla Delphi, C++Buildera i Lazarusa