Iki 3.114.8 versijos PDFiumPas PDF šifravimo rakto medžiagą ne Windows tiksluose generuodavo vykdymo bibliotekos Random funkcija, ir kadangi niekas nekviesdavo Randomize, kiekvienas procesas duodavo tą pačią baitų seką. Free Pascal versijos Linux ir macOS tad rašydavo identiškus failo šifravimo raktus, druskas, CBC IV ir AES-GCM nonce priešdėlius paleidimas iš eilės. 3.114.8 versija vietoj to skaito /dev/urandom ir pakelia išimtį, kai negali
Pats defektas — keturių eilučių ciklas. Naudingesnė pamoka ta, kodėl testų rinkinys, šifruojantis ir iššifruojantis šimtus dokumentų, su AESV3 ir AESV4, su PDF MAC ir be jo, visą laiką liko žalias. Kiekvienam procesui pastovus atsitiktinumas nepastebimas kiekvienam viename procese einančiam testui, o būtent taip paprastai ir rašomi šifravimo testai
Kur PDFiumPas reikia atsitiktinių baitų?
Kiekvienas atsitiktinis baitas PDFiumPas šifravimo krūvoje ateina iš vienos procedūros — AesGenerateRandomBytes FPdfAes modulyje, tad vienas blogas šaltinis užteršia viską. Standartinis saugumo apdorotojas ISO 32000-2 §7.6.4 ir AESV4 plėtinys ISO/TS 32003 tuos baitus vartoja šiose vietose:
- 32 baitų failo šifravimo raktas, kiekvienam dokumentui šviežias sugeneruojamas DeriveEncryptionKeys ir tada suvyniojamas į /UE ir /OE po slaptažodžio išvestais raktais
- Dvi 16 baitų druskos, viena saugoma paskutiniuose 16 /U baitų, kita — paskutiniuose 16 /O baitų, kiekviena padalinta į 8 baitų patikros druską ir 8 baitų rakto druską
- 12–15 baitai atvirųjų duomenų už /Perms, kuriuos ISO 32000-2 užpildo atsitiktiniais duomenimis dar prieš bloką šifruojant failo raktu
- 16 baitų CBC IV, priklijuojamas prie kiekvienos užšifruotos eilutės ir srauto AESV3 dokumente
- 8 baitų nonce priešdėlis AESV4 dokumentams, po jo — 4 baitų skaitiklis vienam objektui, pradedantis nuo nulio
- 32 baitų /KDFSalt ir MAC raktas, kai nustatytas EnableIntegrityProtection
Kodėl kiekvienas procesas duodavo tą patį raktą?
AesGenerateRandomBytes operacinės sistemos generatorių naudojo tik Windows; visur kitur buferį pildavo iš RTL pseudoatsitiktinių skaičių generatoriaus, o tas generatorius pradeda nuo RandSeed = 0, nebent programa kviečia Randomize. Komentaras virš ciklo teigė, kad generatorius pasėtas iš GetTickCount64. Nė viena kodo eilutė to niekada nedarė, tad komentaras liko vienintelė vieta, kur sėkla egzistavo:
// Ne Windows AesGenerateRandomBytes šaka iki 3.114.8
// (komentaras virš jos žadėjo GetTickCount64 sėklą, kuri niekada nebuvo pritaikyta)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
Seka paleidžiama iš naujo kiekvienam procesui ir eina pirmyn jo viduje, tad pirmasis dokumentas, kurį šifruoja bet koks procesas, dalijasi savo failo raktu su kiekvieno kito to paties build'o proceso pirmuoju dokumentu, antrasis — su antruoju, ir toliau. Failo raktas R5, R6 ir R7 apskritai nepriklauso nuo slaptažodžio, nes slaptažodis jį tik apvynioja, tad kas tik sugeba atkurti seką, laiko raktą nežinodamas jokio slaptažodžio. AESV4 prideda antrą nesėkmę: tas pats raktas su tą pačiu 8 baitų priešdėliu ir skaitikliu, vėl pradedančiu nuo nulio, kartuoja GCM nonce, ką NIST SP 800-38D §8 draudžia kategoriškai. Pakartotas GCM nonce po vienu raktu atskleidžia dviejų atvirųjų tekstų XOR ir išplėšia autentifikacijos poraktį, tad žymos, kuriomis remiasi AESV4-GCM šifravimas ir PDF MAC tokenas, nustoja reikšti ką nors. Konfidencialumas ir vientisumas prarandami kartu
Apimtis siauresnė, nei tas paragrafas galėtų pasiūlyti. Windows versijos niekada nenukentėjo, nes Windows šaka visada kviesdavo CryptGenRandom per advapi32 su CRYPT_VERIFYCONTEXT ir keldavo išimtį, kai tai nesisekdavo. Atvira buvo ne Windows versijų, senesnių nei 3.114.8, išvestis, kas praktikoje reiškia Lazarus ir Free Pascal programas Linux ir macOS — dar vienas įrašas į Delphi ir FPC spąstų PDFium versijose sąrašą
Kodėl Randomize niekada nebuvo teisinga pataisa?
Randomize iškvietimas būtų paslėpęs simptomą nesutvarkęs šaltinio, nes RandSeed yra 32 bitų reikšmė, o Randomize ją išveda iš laikrodžio. Tai apriboja galimų raktų sekų skaičių iki 2^32, o apytikslė žinoma failo rašymo data paiešką nukerta gerokai žemiau to, kas niekas šalia 256 bitų AES rakto. Rakto medžiaga turi ateiti iš branduolio entropijos baseino, tad AesGenerateRandomBytes 3.114.8 skaito /dev/urandom, cikle gaudo trumpus skaitymus ir kelia išimtį, jei baseinas negali atiduoti kiekvieno prašomo baito:
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; // nesėkmė ar netikėta srauto pabaiga
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');
Atsisakymas sąmoningas ir atitinka tai, ką Windows šaka visada darydavo, kai CryptGenRandom neprieinamas. Nepavykęs užšifruotas įrašymas — incidentas, kurį pastebite tą pačią dieną; sėkmingas įrašymas su nuspėjamais raktais — toks, apie kurį sužinosite iš kitų. Dvi praktinės išvados. Minimalus konteineris ar chroot be užpildyto /dev dabar žlunga šifruodamas, užuot tyliai smukdamas, tad jį prijunkite. Ir kadangi išimtis išsiverčia iš TPdf.SaveAsEncrypted jau atvėrus tikslinį failą su fmCreate, po savęs lieka tuščias išvesties failas, kurį sutvarkyti lieka jūsų klaidų handleriui
Kodėl pirmyn-atgal testai to niekada nesugavo?
Pirmyn-atgal testas nemato pastovaus atsitiktinumo, nes iššifravimas atgauna bet kurį failo raktą, kurį pasirinko šifravimas. Testas užšifruoja dokumentą, vėl jį atidaro su slaptažodžiu, išvynioja raktą iš /UE ir iššifruoja kiekvieną objektą; nuspėjamas raktas išvyniojamas ir iššifruojamas taip pat gerai kaip atsitiktinis, o GCM žymos patvirtinamos, nes apskaičiuotos su tą pačiu raktu. Net testas, šifruojantis du kartus ir reikalaujantis, kad abi išvestys skirtųsi, praeina, nes antrasis kvietimas tame pačiame procese semia sekančius sekos baitus. Savybė, kuri svarbi — kitas raktas kiekviename procese — stebima tik lyginant išvestį tarp procesų. Kai tas pats kodas ir gamina, ir vartoja reikšmę, testai yra akli visoms defektų klasėms, o atsitiktinumas — tyriausias pavyzdys
Kaip patikrinti rakto atsitiktinumą tarp procesų?
Paleiskite mažą zondą du kartus kaip atskirus procesus tikslinėje platformoje ir palyginkite išvestį. Žemiau esantis zondas kviečia DeriveEncryptionKeys ir išspausdina druską, saugomą /U įrašo baituose 32–47. Ta reikšmė rašoma visiems matomoje vietoje kiekviename užšifruotame faile, tad jos spausdinimas CI žurnaluose nieko neatskleidžia, bet ji ateina iš to paties generatoriaus kaip failo raktas:
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 = 32 baitų maiša + 16 baitų druska
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // turi skirtis kiekvieną paleidimą
end.
Prijunkite zondą prie build'o kiekvienam ne Windows tikslui: paleiskite du kartus, žlugdykite užduotį, jei abi eilutės sutampa. Tas pats palyginimas tinka ir jau apyvartoje esantiems failams. Imkite du užšifruotus PDF, parašytus skirtingais tos pačios programos paleidimais, perskaitykite /U eilutes iš jų Encrypt žodynų ir palyginkite paskutinius 16 baitų; identiškos druskos atpažįsta pažeistą build'ą, o dokumentus reikia užšifruoti iš naujo iš jų atvirųjų duomenų su 3.114.8 ar vėlesne, kad kiekvienas gautų šviežią failo raktą. Bendras įprotis — ne Windows kodo kelius išbandyti pačioje platformoje, užuot pasitikėjus Windows paleidimu, ta pati logika slypi už libcurl laiko žymų backend ne Windows versijoms
PDFiumPas yra Delphi ir Lazarus PDF komponentas, pastatytas ant PDFium variklio, su AES-256, AES-GCM ir PDF MAC tokenu, įgyvendintais savomis Pascal jėgomis, ir rakto medžiaga semama iš operacinės sistemos generatoriaus kiekvienoje platformoje. Detalės ir parsisiuntimai — PDFium Delphi komponento puslapyje