Artykuł techniczny

Dlaczego niektóre PDF-y AES-256 R6 odrzucają poprawne hasło

Gdy PDF w AES-256 rewizji 6 odrzuca hasło, o którym wiesz, że jest poprawne, zwykłym winowajcą jest licznik rund w utwardzonym hashu R6 (ISO 32000-2, algorytm 2.B). PDFium Component dla Delphi miał dokładnie ten błąd przed v2.57.2: TPdf.SaveAsEncrypted liczył rundy od złej bazy, i około 30% produkowanych przez niego plików było odrzucanych przez FPDF_LoadDocument z FPDF_ERR_PASSWORD

Paskudne nie jest tu liczenie. Paskudne jest to, że wada była niewidoczna dla każdego testu, który zaszyfrował plik, a potem sprawdził go tym samym kodem, i to, że jedna udana podróż w obie strony nie dowodziła nic. Ten wpis przechodzi przez to, o co naprawdę prosi algorytm 2.B, gdzie ukrywa się przesunięcie o jedną rundę, dlaczego pada tylko czasami i jak zbudować test regresji, który złapie taki błąd, zanim zrobi to klient

Co naprawdę mówi reguła zakończenia hasha R6?

Algorytm 2.B staje dopiero po co najmniej 64 rundach, i to tylko wtedy, gdy ostatni bajt najświeższego wyjścia AES E jest mniejszy lub równy numerowi rundy minus 32, gdzie numer rundy to liczone od jedynki wykonanie już wykonanych rund. Po n-tej rundzie porównujesz z N. Nie z N − 1, nie z indeksem następnej rundy. Ta jedna definicja to cała powierzchnia błędu

Reszta algorytmu jest mechaniczna. Startujesz z K jako SHA-256 z hasła, soli i (dla klucza właściciela) 48-bajtowej wartości /U. Każda runda buduje K1 jako hasło ‖ K ‖ UK powtórzone 64 razy, szyfruje je AES-128-CBC bez wypełnienia, używając pierwszych 16 bajtów K jako klucza i następnych 16 jako IV, bierze pierwsze 16 bajtów E jako big-endian liczbę 128-bit mod 3 i wybiera odpowiednio SHA-256, SHA-384 albo SHA-512, by wyprodukować następne K. Ostateczny hash to pierwsze 32 bajty K. ISO 32000-2 §7.6.4.3 to definiuje; długość pętli zależna od danych jest zamierzona, bo czyni pracę na zgadywanie hasła nieprzewidywalną dla crackera GPU

Oto poprawny kształt pętli, zapisany jako ilustracja, nie implementacja wtykana. BuildK1, AesCbcNoPad128, FirstBlockMod3 i pomocniki hashujące zastępują to, za jakie podstawy twój codebase już ma

// Ilustracja: szkielet pętli algorytmu 2.B (ISO 32000-2, rewizja 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // wykonane rundy do tej pory
repeat
  Inc(RoundNumber);               // to jest runda 1, 2, 3 ...
  K1 := BuildK1(Password, K, UK); // (Password || K || UK) powtórzone 64 razy
  E  := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
  case FirstBlockMod3(E) of       // pierwsze 16 bajtów E, big-endian, mod 3
    0: K := Sha256(E);
    1: K := Sha384(E);
    2: K := Sha512(E);
  end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);

Czytaj linię zakończenia z licznikiem w głowie. Inkrementacja siedzi na górze ciała, więc w chwili, gdy test until rusza, RoundNumber trzyma dokładnie liczbę dokończonych rund, a to jest to, co standard znaczy przez numer rundy

Gdzie off-by-one się chowa w portach na Delphi

Błąd PDFium Component to licznik startujący od −1 z inkrementacją na górze pętli, więc wartość testowana w klauzuli until była zawsze liczbą wykonanych rund minus jeden. Dwie rzeczy psują się naraz: minimum 64 rund staje się 65, a próg bajtu jest o jeden stopień ciaśniejszy, niż mówi standard. Oba przesuwają punkt wyjścia dokładnie o jedną rundę w przypadkach, w których to ma znaczenie

Ta sama wpadka ma kilka równie wiarygodnych przebrań. Zacznij od 0 i inkrementuj na dole ciała, po tym, jak test został już napisany wobec wartości sprzed inkrementacji. Użyj indeksu pętli for liczonego od zera i użyj go ponownie w porównaniu. Przetłumacz referencję C, której zmienna pętli jest sprawdzana przed ++. Każde z tych czyta się naturalnie w code review i każde produkuje hash wyglądający na kompletnie losowy, bo taki jest: to po prostu inne, równie dobrze wyglądające wyjście SHA, którego żaden inny czytnik nie policzy. Naprawiony CalculateR6Hash w FPdfEncrypt.pas niesie teraz komentarz przypinający semantykę liczoną od jedynki, właśnie dlatego, że zła wersja nie wygląda na złą

Dlaczego PDF AES-256 R6 pada tylko czasami?

Porażka jest probabilistyczna, bo dodatkowa runda zmienia wynik tylko wtedy, gdy bajt kończący wyląduje w jednoobrotowym oknie między poprawnym progiem a przesuniętym. W każdym innym przypadku obie wersje wychodzą na tej samej rundzie i produkują to samo K, więc plik otwiera się dobrze. W pomiarze PDFium Component 18 z 60 szyfrowań (około 30%) wyprodukowało wartości /U, /O, /UE i /OE niezgodne z implementacją standardową; pozostałe 70% było poprawnych bajt po bajcie

Świeże losowe sole są losowane przy każdym zapisie, więc ten sam dokument i to samo hasło wychodzą w jednym uruchomieniu i padają w następnym. Z biurka wsparcia wygląda to jak kapryśna pamięć, uszkodzony upload albo użytkownik literówką psujący hasło. Nikt nie myśli o obwinianiu licznika pętli, gdy siedem z dziesięciu plików się otwiera. Jeśli twój produkt widzi przerywane zgłoszenia "złe hasło" na PDF-ach, które zaszyfrował twój własny kod, i tylko w rewizji 6, to jest pierwsze miejsce do zajrzenia

Rewizja 5 jest odporna, bo używa pojedynczego przebiegu SHA-256 bez iteracji. Ścieżka AESV4 z ISO/TS 32003 już nie: wartość erR7 w TPdfEncryptionRevision używa algorytmu 2.B do swoich hashy haseł, więc ta sama poprawka pokrywa pliki GCM opisane w artykule o szyfrowaniu AES-256-GCM i integralności PDF MAC z ISO/TS 32003

Pułapka samozgodności: dlaczego testy w obie strony przechodzą

Podróż w obie strony przez własne szyfrowanie i własną weryfikację zawsze się udaje, bo obie strony puszczają tę samą złą pętlę i zgadzają się na tym samym złym hashu. Wada istnieje tylko względem czyjejś implementacji, więc sędzią musi być ktoś niezależny: silnik PDFium w pdfium.dll, Acrobat, qpdf albo jakikolwiek czytnik, którego nie napisałeś

To nieprzyjemna ogólna lekcja dla każdego, kto pisze kod dokumentowy przy kryptografii. Testy jednostkowe, które liczą hash i porównują go z wyjściem testowanej funkcji, dowodzą determinizmu, nie poprawności. Wektory znanych odpowiedzi ze standardu albo z narzędzia referencyjnego są lepsze, a podróż w obie strony między implementacjami to najbliższe prawdy gruntowej, co dostaniesz. PDFium Component jest tu w szczęśliwej pozycji: szyfrowanie robi się w Pascalu przez EncryptDocumentBytes, a deszyfrowanie przy ładowaniu idzie przez własną obsługę bezpieczeństwa C++ PDFium, więc ponowne otwarcie zapisanego pliku przez TPdf to prawdziwa niezależna kontrola, nie lustro

uses
  SysUtils, PDFium, FPdfEncrypt;

procedure SaveR6(const Source, Target: string);
var
  Pdf: TPdf;
  Opts: TPdfEncryptOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Opts := TPdfEncryptOptions.Default;
    Opts.UserPassword := 'userpw';
    Opts.OwnerPassword := 'ownerpw';
    Opts.Revision := erR6;          // AESV3, hash hasła algorytmu 2.B
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

TPdfEncryptOptions.Default startuje od rewizji 5 ze wszystkimi uprawnieniami dozwolonymi i metadanymi szyfrowanymi, więc ustawiaj Revision jawnie, gdy chcesz R6. TPdf.SaveAsEncryptedToStream bierze ten sam rekord opcji, gdy wyjście idzie do TStream zamiast do pliku. Zauważ, że hasła w rekordzie opcji to AnsiString, co ma znaczenie, gdy przekazujesz poświadczenia nie-ASCII; znormalizuj i zakoduj je, zanim dotrą do rekordu

Jak robić test regresji probabilistycznego błędu szyfrowania?

Powtarzaj podróż w obie strony między implementacjami na tyle razy, żeby 30-procentowa porażka nie przecisnęła się. Pojedynczy przebieg gubi błąd PDFium Component około 70% czasu. Dwadzieścia uruchomień, każde ze świeżymi solami, zgniata stopień chybienia do około 0,08% (0,7 do dwudziestej potęgi), dlatego wysłany test regresji kręci pętlę 20 razy, a nie raz

procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
  Pdf: TPdf;
  I: Integer;
  Target: string;
begin
  Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
  for I := 1 to 20 do
  begin
    SaveR6(SamplePdf, Target);       // świeże sole przy każdym zapisie
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // PDFium weryfikuje /U niezależnie
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

Dobierz do tego przypadek negatywny, złe hasło, które musi paść, żeby zepsuty loader nie mógł przepuścić testu pozytywnego na darmo. A jeśli budujesz w Lazarusie, przebuduj pakiet biblioteki przed uruchomieniem projektu testowego po dotknięciu Lib\*.pas; build testowy linkuje prekompilowane jednostki .ppu pakietu, a puszczenie przeciw nieświeżemu kodowi obiektowemu daje ci pocieszającą zieleń na błędzie, którego faktycznie nie naprawiłeś. Zasiew to pokrewna pułapka warta przeglądu w tym samym przebiegu: FPC Random produkujący stałe klucze szyfrowania to kolejny przypadek, gdzie wyjście wygląda losowo i po cichu jest złe

FAQ

Czy błąd licznika rund R6 dotyka plików zaszyfrowanych w rewizji 5?

Nie. Rewizja 5 hashuje hasło pojedynczym przebiegiem SHA-256 i nie ma licznika iteracji, więc nie ma licznika rund do sparcia. Dotknięte są tylko rewizja 6 i rewizja AESV4 z ISO/TS 32003, które obie używają algorytmu 2.B

Czy plik napisany przez zepsutą wersję da się jeszcze otworzyć?

Około 70% otwiera się normalnie, bo ich hash akurat się zgadzał. Reszta niesie wartości /U i /O, których żaden standardowy czytnik nie odtworzy, więc sprawdzenie hasła pada wszędzie; praktyczną poprawką jest ponowne zaszyfrowanie ze źródła w postaci jawnej wersją v2.57.2 albo nowszą

Dlaczego testować przeciw PDFium, a nie przeciw własnemu kodowi deszyfrowania?

Twój własny deszyfrator dzieli tę samą implementację algorytmu 2.B co twój szyfrator, więc zła liczba rund się znosi i każda podróż w obie strony przechodzi. Niezależna implementacja, jak obsługa bezpieczeństwa PDFium, to jedyne sprawdzenie, które może się z tobą nie zgodzić

Szyfrowanie, walidacja PDF MAC i reszta potoku zapisu są częścią PDFium Component dla Delphi i C++Builder, ze źródłami i testami regresji dla Delphi i Lazarusa