Vóór versie 3.114.8 genereerde PDFiumPas PDF-encryptiesleutelmateriaal op niet-Windows-doelen met de Random-functie van de runtime library, en omdat niets Randomize aanriep, produceerde elk proces dezelfde bytereeks. Free Pascal-builds op Linux en macOS schreven dus identieke bestandsversleutelingssleutels, salts, CBC-IV's en AES-GCM-nonceprefixen, run na run. Versie 3.114.8 leest in plaats daarvan /dev/urandom en gooit een exceptie als dat niet lukt
Het defect zelf is een lus van vier regels. De nuttigere les is waarom een testsuite die honderden documenten versleutelt en ontsleutelt, met AESV3 en AESV4, met en zonder PDF MAC, de hele tijd groen bleef. Willekeur die per proces constant is, is onzichtbaar voor elke test die binnen één proces draait, en precies zo worden encryptietests doorgaans geschreven
Waar heeft PDFiumPas willekeurige bytes nodig?
Elke willekeurige byte in de PDFiumPas-encryptiestapel komt uit één procedure, AesGenerateRandomBytes in de unit FPdfAes, dus één slechte bron verontreinigt alles tegelijk. De standaard security handler in ISO 32000-2 §7.6.4 en de AESV4-uitbreiding in ISO/TS 32003 verbruiken die bytes op deze plekken:
- De bestandsversleutelingssleutel van 32 byte, door DeriveEncryptionKeys per document vers aangemaakt en daarna onder wachtwoord-afgeleide sleutels gewikkeld in /UE en /OE
- Twee salts van 16 byte, één opgeslagen in de laatste 16 bytes van /U en één in de laatste 16 bytes van /O, elk gesplitst in een validatiesalt van 8 byte en een keysalt van 8 byte
- Bytes 12 tot 15 van de plaintext achter /Perms, die ISO 32000-2 met willekeurige data vult voordat het blok onder de bestandssleutel wordt versleuteld
- Een CBC-IV van 16 byte die aan elke versleutelde string en stream in een AESV3-document wordt voorgehangen
- Een nonceprefix van 8 byte voor AESV4-documenten, gevolgd door een per-object teller van 4 byte die op nul begint
- De /KDFSalt van 32 byte en de MAC-sleutel wanneer EnableIntegrityProtection is gezet
Waarom produceerde elk proces dezelfde sleutel?
AesGenerateRandomBytes gebruikte de generator van het besturingssysteem alleen op Windows; overal elders vulde hij de buffer uit de pseudo-willekeursgenerator van de RTL, en die generator begint bij RandSeed = 0 tenzij het programma Randomize aanroept. Het commentaar boven de lus zei dat de generator geseed werd vanuit GetTickCount64. Geen enkele regel code deed dat ooit, waarmee het commentaar de enige plek was waar de seed bestond:
// Niet-Windows-tak van AesGenerateRandomBytes vóór 3.114.8
// (het commentaar erboven beloofde een GetTickCount64-seed die nooit werd toegepast)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
De reeks herstart bij elk proces en schuift binnen het proces op, dus het eerste document dat een proces versleutelt deelt zijn bestandssleutel met het eerste document van elk ander proces dat dezelfde build draait, het tweede met het tweede, enzovoort. De bestandssleutel in R5, R6 en R7 hangt helemaal niet van het wachtwoord af, want het wachtwoord wikkelt hem slechts in, wat betekent dat iedereen die de reeks kan reproduceren de sleutel in handen heeft zonder een wachtwoord te kennen. AESV4 voegt een tweede faling toe: dezelfde sleutel met hetzelfde prefix van 8 byte en een teller die op nul herstart herhaalt GCM-nonces, wat NIST SP 800-38D §8 ronduit verbiedt. Een herhaalde GCM-nonce onder één sleutel onthult de XOR van de twee plaintexts en legt de authenticatie-subkey bloot, dus de tags waarop AESV4-GCM-versleuteling en het PDF MAC-token steunen, betekenen niets meer. Vertrouwelijkheid en integriteit gaan tegelijk verloren
De omvang is kleiner dan die al suggereert. Windows-builds zijn nooit geraakt, want de Windows-tak heeft altijd CryptGenRandom aangeroepen via advapi32 met CRYPT_VERIFYCONTEXT en gooide een exceptie als dat faalde. Wat was blootgesteld, was de uitvoer van niet-Windows-builds ouder dan 3.114.8, wat in de praktijk betekent: Lazarus- en Free Pascal-applicaties op Linux en macOS, weer één invoer voor de lijst met Delphi-versus-FPC-valkuilen in PDFium-builds
Waarom was Randomize nooit de juiste fix?
Randomize aanroepen zou het symptoom hebben verhuld zonder de bron te verhelpen, want RandSeed is een 32-bit waarde en Randomize leidt die af van de klok. Dat begrenst het aantal mogelijke sleutelreeksen op 2^32, en grofweg weten wanneer een bestand is geschreven knipt de zoekruimte ver onder dat niveau, wat niets is naast een AES-sleutel van 256 bit. Sleutelmateriaal moet uit de entropiepool van de kernel komen, dus leest AesGenerateRandomBytes in 3.114.8 /dev/urandom, loopt hij over korte reads heen en gooit hij een exceptie als de pool niet elke gevraagde byte kan leveren:
Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
Remaining := Count;
while Remaining > 0 do
begin
Got := FileRead(Handle, P^, Remaining);
if Got <= 0 then
Break; // mislukking of onverwacht einde van de stream
Inc(P, Got);
Dec(Remaining, Got);
end;
if Remaining = 0 then
Exit;
finally
FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');
Weigeren is bewust, en het sluit aan bij wat de Windows-tak altijd heeft gedaan wanneer CryptGenRandom onbeschikbaar is. Een mislukte versleutelde opslag is een incident dat u dezelfde dag nog merkt; een geslaagde opslag met voorspelbare sleutels is er één waar u van iemand anders hoort. Twee praktische gevolgen volgen daaruit. Een minimale container of chroot zonder gevulde /dev faalt nu bij versleuteling in plaats van geruisloos te degraderen, dus mount hem. En omdat de exceptie zich uit TPdf.SaveAsEncrypted verspreidt nadat het doelbestand met fmCreate is geopend, blijft een leeg uitvoerbestand achter dat uw foutafhandeling kan opruimen
Waarom vingen round-trip-tests dit nooit?
Een round-trip-test kan constante willekeur niet zien, omdat ontsleutelen welke bestandssleutel de versleuteling ook koos, terugwint. De test versleutelt een document, opent het opnieuw met het wachtwoord, wikkelt de sleutel uit /UE uit en ontsleutelt elk object; een voorspelbare sleutel pakt en ontsleutelt precies even goed als een willekeurige, en GCM-tags verifiëren omdat ze met diezelfde sleutel zijn berekend. Zelfs een test die twee keer versleutelt en controleert of de twee uitvoeren verschillen, slaagt, want de tweede aanroep in hetzelfde proces trekt de volgende bytes uit de reeks. De eigenschap die telt, een andere sleutel in elk proces, is alleen waarneembaar door uitvoer tussen processen te vergelijken. Waar dezelfde code een waarde zowel produceert als verbruikt, zijn de tests blind voor hele klassen van defecten, en willekeur is het zuiverste voorbeeld
Hoe test u sleutelwillekeur over processen heen?
Draai een kleine probe twee keer als aparte processen op het doelplatform en vergelijk de uitvoer. De probe hieronder roept DeriveEncryptionKeys aan en drukt het salt af dat in bytes 32 tot 47 van de /U-invoer staat. Die waarde wordt open en bloot in elk versleuteld bestand geschreven, dus het afdrukken in CI-logboeken onthult niets, maar hij komt uit dezelfde generator als de bestandssleutel:
program SaltProbe;
{$mode delphi}
uses
SysUtils, FPdfEncrypt;
var
Opts: TPdfEncryptOptions;
Keys: TPdfEncryptionKeys;
I: Integer;
Hex: string;
begin
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'probe';
Opts.Revision := erR6;
DeriveEncryptionKeys(Opts, Keys);
Hex := '';
for I := 32 to 47 do // /U = hash van 32 byte + salt van 16 byte
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // moet bij elke run verschillen
end.
Knoopt de probe aan de build voor elk niet-Windows-doel: draai hem twee keer en laat de job falen als de twee regels matchen. Dezelfde vergelijking werkt op bestanden die al in omloop zijn. Neem twee versleutelde PDF's geschreven door verschillende runs van dezelfde applicatie, lees de /U-strings uit hun Encrypt-dictionaries en vergelijk de laatste 16 bytes; identieke salts wijzen op een geraakte build, en de documenten moeten opnieuw vanaf hun plaintext worden versleuteld met 3.114.8 of nieuwer zodat elk er een verse bestandssleutel krijgt. De algemene gewoonte is om niet-Windows-codepaden op het platform zelf te oefenen in plaats van op de Windows-run te vertrouwen, dezelfde redenering achter de libcurl-timestamp-backend voor niet-Windows-builds
PDFiumPas is een Delphi- en Lazarus-PDF-component gebouwd op de PDFium-engine, met AES-256, AES-GCM en het PDF MAC-token natively geïmplementeerd in Pascal en sleutelmateriaal dat op elk platform uit de generator van het besturingssysteem komt. Details en downloads staan op de PDFium Delphi componentpagina