Wanneer een PDF met AES-256 revisie 6 een wachtwoord weigert waarvan u weet dat het klopt, is de gebruikelijke dader de rondeteller in de R6-hardened hash (ISO 32000-2 Algorithm 2.B). De PDFium Component for Delphi had precies deze bug vóór v2.57.2: TPdf.SaveAsEncrypted telde rondes vanaf de verkeerde basis, en ruwweg 30% van de bestanden die hij produceerde werd geweigerd door FPDF_LoadDocument met FPDF_ERR_PASSWORD
Het gemene zit niet in de rekenkunde. Het gemene zit in het feit dat het defect onzichtbaar was voor elke test die een bestand versleutelde en het daarna met dezelfde code controleerde, en dat één geslaagde round trip niets bewees. Dit bericht loopt langs wat Algorithm 2.B werkelijk vraagt, waar een slip van één ronde zich verstopt, waarom het maar soms faalt, en hoe u een regressietest bouwt die zo'n bug vangt voordat een klant hem vindt
Wat zegt de beëindigingsregel van de R6-hash werkelijk?
Algorithm 2.B stopt pas na minstens 64 rondes, en dan alleen wanneer de laatste byte van de meest recente AES-uitvoer E kleiner of gelijk is aan het rondenummer min 32, waarbij het rondenummer de 1-based telling is van de al uitgevoerde rondes. Na de Nde ronde vergelijkt u met N. Niet met N − 1, niet met de index van de volgende ronde. Die ene definitie is het hele bugoppervlak
De rest van het algoritme is mechanisch. Begin met K als de SHA-256 van wachtwoord, salt en (voor de ownersleutel) de 48-byte /U-waarde. Elke ronde bouwt K1 als wachtwoord ‖ K ‖ UK 64 keer herhaald, versleutelt het met AES-128-CBC zonder padding, met de eerste 16 bytes van K als sleutel en de volgende 16 als IV, pakt de eerste 16 bytes van E als een big-endian 128-bits integer mod 3, en kiest daarop basis SHA-256, SHA-384 of SHA-512 om de volgende K te produceren. De uiteindelijke hash is de eerste 32 bytes van K. ISO 32000-2 §7.6.4.3 definieert hem; de dataveranderlijke luslengte is opzettelijk, want die maakt het werk per wachtwoordgok onvoorspelbaar voor een GPU-cracker
Hier is de juiste vorm van de lus, geschreven als illustratie in plaats van als kant-en-klare implementatie. BuildK1, AesCbcNoPad128, FirstBlockMod3 en de hash-helpers staan op de plek van de primitieven die uw codebase al heeft
// Illustratie: Algorithm 2.B-loop-skelet (ISO 32000-2, revisie 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0; // uitgevoerde rondes tot dusver
repeat
Inc(RoundNumber); // dit is ronde 1, 2, 3 ...
K1 := BuildK1(Password, K, UK); // (Password || K || UK) 64 keer herhaald
E := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
case FirstBlockMod3(E) of // eerste 16 bytes van 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);
Lees de beëindigingsregel met de teller in gedachten. De verhoging zit bovenaan de body, dus op het moment dat de until-test draait, bevat RoundNumber exact het aantal voltooide rondes — wat de standaard bedoelt met het rondenummer
Waar de off-by-one zich verstopt in een Delphi-port
De bug in de PDFium Component was een teller die op −1 begon met de verhoging bovenaan de lus, dus de waarde die in de until-clausule getest werd was altijd het aantal uitgevoerde rondes min één. Er gaan tegelijk twee dingen mis: het minimum van 64 rondes wordt 65, en de bytedrempel staat één stand strakker dan de standaard zegt. Beide verschuiven het uitgangspunt met precies één ronde in de gevallen waarin het telt
Dezelfde slip heeft verschillende even plausibele vermommingen. Begin op 0 en verhoog onderaan de body, nadat de test al tegen de pre-verhogingswaarde is geschreven. Gebruik een for-lusindex die 0-based is en hergebruik hem in de vergelijking. Vertaal een C-referentie waarvan de lusvariabele vóór ++ wordt gecontroleerd. Elk van deze leest natuurlijk in een codereview, en elk produceert een hash die er perfect willekeurig uitziet, omdat hij dat ook is: het is gewoon een andere, net zo geldig ogende SHA-uitvoer die geen enkele andere lezer berekent. De gefixte CalculateR6Hash in FPdfEncrypt.pas draagt nu een commentaarregel die de 1-based semantiek vastpint, juist omdat de verkeerde versie er niet verkeerd uitziet
Waarom faalt een AES-256 R6-PDF maar soms?
De mislukking is probabilistisch, want de extra ronde verandert het resultaat alleen wanneer de beëindigende byte landt in het venster van één ronde tussen de juiste drempel en de verschoven. In elk ander geval stoppen beide versies in dezelfde ronde en produceren dezelfde K, dus het bestand opent gewoon. In de meting van de PDFium Component gaven 18 van 60 versleutelingen (ongeveer 30%) /U-, /O-, /UE- en /OE-waarden die afweken van een standaardimplementatie; de andere 70% was byte-voor-byte correct
Bij elke opslag worden verse random salts getrokken, dus hetzelfde document met hetzelfde wachtwoord slaagt in de ene run en faalt in de volgende. Vanuit een servicedesk oogt dat als wisselvallige opslag, een corrupte upload of een gebruiker die zich verslikt in het wachtwoord. Niemand denkt eraan een lussteller de schuld te geven wanneer zeven van de tien bestanden openen. Ziet uw product incidentele "wrong password"-meldingen op PDF's die uw eigen code heeft versleuteld, en alleen bij revisie 6, dan is dit de eerste plek om te kijken
Revisie 5 is immuun, want die gebruikt één SHA-256-pas zonder iteratie. Het ISO/TS 32003 AESV4-pad is dat niet: TPdfEncryptionRevision-waarde erR7 hergebruikt Algorithm 2.B voor zijn wachtwoordhashes, dus dezelfde fix dekt de GCM-bestanden die worden beschreven in AES-256-GCM-versleuteling en PDF MAC-integriteit met ISO/TS 32003
De zelfconsistentie-valkuil: waarom round-trip-tests slagen
Een round trip door uw eigen versleuteling en uw eigen verificatie slaagt altijd, want beide kanten draaien dezelfde verkeerde lus en zijn het eens over dezelfde verkeerde hash. Het defect bestaat alleen ten opzichte van de implementatie van iemand anders, dus de rechter moet een onafhankelijke zijn: de PDFium-engine in pdfium.dll, Acrobat, qpdf, of welke lezer u ook niet schreef
Dit is de ongemakkelijke algemene les voor iedereen die documentcode in de buurt van crypto schrijft. Unittests die een hash berekenen en vergelijken met de uitvoer van de functie onder test bewijzen determinisme, geen correctheid. Known-answer-vectors uit de standaard of uit een referentietool zijn beter, en een cross-implementatie-round trip is het dichtste bij ground truth dat u krijgt. De PDFium Component zit hier in een gunstige positie: versleuteling gebeurt in Pascal door EncryptDocumentBytes, terwijl decryptie bij het laden door PDFium's eigen C++ security handler gaat, dus het heropenen van het opgeslagen bestand met TPdf is een echte onafhankelijke controle in plaats van een spiegel
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, Algorithm 2.B-wachtwoordhash
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('SaveAsEncrypted failed');
finally
Pdf.Free;
end;
end;
TPdfEncryptOptions.Default begint op revisie 5 met alle permissies toegestaan en metadata versleuteld, dus zet Revision expliciet wanneer u R6 wilt. TPdf.SaveAsEncryptedToStream neemt hetzelfde options-record wanneer de uitvoer naar een TStream gaat in plaats van naar een bestand. Let op dat de wachtwoorden AnsiString zijn in het options-record, wat telt wanneer u niet-ASCII-credentials doorgeeft; normaliseer en codeer ze voordat ze het record bereiken
Hoe regression-test u een probabilistische versleutelingsbug?
Herhaal de cross-implementatie-round trip vaak genoeg dat een faalkans van 30% er niet doorheen kan glippen. Eén enkele run mist de bug van de PDFium Component zo'n 70% van de tijd. Twintig runs, elk met verse salts, drukken de misserkans naar ongeveer 0,08% (0,7 tot de twintigste macht), en daarom loopt de meegeleverde regressietest 20 keer in plaats van één keer
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); // verse salts bij elke opslag
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'userpw';
Pdf.FileName := Target;
Pdf.Active := True; // PDFium verifieert /U onafhankelijk
AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
finally
Pdf.Free;
end;
end;
end;
Zet er het negatieve geval naast, een verkeerd wachtwoord dat moet falen, zodat een kapotte loader de positieve test niet ledig kan laten slagen. En bouwt u met Lazarus, bouw het library-pakket dan opnieuw voordat u na het aanraken van Lib\*.pas het testproject draait; de testbuild linkt de precompiled .ppu-units van het pakket, en draaien tegen verouderde objectcode geeft u een geruststellend groen op een bug die u eigenlijk niet heeft gefixt. Seeding is een verwante valkuil die het bekijken waard is in dezelfde slag: FPC Random dat constante versleutelingssleutels produceert is nog een geval waar de uitvoer willekeurig oogt en stilletjes fout is
FAQ
Raakt de R6-rondeteller-bug bestanden die met revisie 5 zijn versleuteld?
Nee. Revisie 5 hasht het wachtwoord met één SHA-256-pas en heeft geen iteratietelling, dus er is geen rondeteller die verkeerd kan. Alleen revisie 6 en de ISO/TS 32003 AESV4-revisie, die allebei Algorithm 2.B gebruiken, zijn geraakt
Kan een bestand geschreven door de buggy-versie nog worden geopend?
Ongeveer 70% opent normaal omdat hun hash toevallig matchte. De rest voert /U- en /O-waarden mee die geen standaardlezer reproduceert, dus de wachtwoordcontrole faalt overal; de praktische fix is opnieuw versleutelen vanaf de plaintext-bron met v2.57.2 of nieuwer
Waarom testen tegen PDFium in plaats van mijn eigen decryptiecode?
Uw eigen decryptor deelt dezelfde Algorithm 2.B-implementatie als uw encryptor, dus een verkeerde rondetelling heft zichzelf op en elke round trip slaagt. Een onafhankelijke implementatie zoals PDFium's security handler is de enige controle die het met u oneens kan zijn
Versleuteling, PDF MAC-validatie en de rest van de opslagpijplijn zitten in de PDFium Component for Delphi and C++Builder, met broncode en regressietests voor zowel Delphi als Lazarus