Techninis straipsnis

PDF šifravimo raktų kartojimas FPC: PDFiumPas RNG pataisa

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
Kiekvienas atsitiktinis baitas PDFiumPas šifravimo krūvoje teka iš AesGenerateRandomBytes FPdfAes į šešis vartotojus: 32 baitų failo šifravimo raktas, vyniojamas į /UE ir /OE, /U ir /O druskos, /Perms užpildo baitai, AESV3 CBC IV, AESV4 GCM nonce priešdėlis bei KDF druska ir MAC raktas
Vienas bendras generatorius reiškia, kad vienas blogas šaltinis visur vienu metu užteršia rakto medžiagą, todėl pataisa nusileido vienoje procedūroje, o ne kiekviename kvietimo taške

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

Nepasėtas RTL generatorius PDFiumPas ne Windows FPC versijose: su RandSeed 0 kiekvienas procesas duoda tą pačią seką, tad A proceso pirmasis dokumentas neša tą patį failo raktą kaip B proceso pirmasis dokumentas, o AESV4 kartuoja GCM nonce, nes tas pats raktas sutinka tą patį priešdėlį su skaitikliu, vėl pradedančiu nuo nulio
Kadangi failo raktas niekada nepriklauso nuo slaptažodžio, kas tik sugeba atkurti seką, laiko raktą be jokių sąlygų, o pakartoti GCM nonce sunaikina konfidencialumą ir vientisumą 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:

Tarpprocesinis atsitiktinumo testas PDFiumPas: SaltProbe programa kviečia DeriveEncryptionKeys ir spausdina /U baitų 32–47 heksą, užduotis ją paleidžia du kartus kaip atskirus procesus ir žlunga, kai eilutės sutampa, o išsiųsti PDF lyginami pagal paskutinius 16 savo /U eilučių baitų
Pastovus atsitiktinumas vieno proceso viduje nematomas, nes iššifravimas atgauna bet kurį šifravimo pasirinktą raktą, tad svarbi savybė stebima tik lyginant išvestį tarp procesų
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