PDF nėra dokumentas, kurį tiesiog atidarote. Tai nedidelė programa, kurią paleidžiate. Kiekvienas įterptas šriftas yra dėklu pagrįstas interpretatorius, laukiantis charstring eilučių, kiekvienas vaizdas yra dekoderis, maitinamas failo nurodytais pločio, aukščio ir bitų gylio laukais, o kiekvienas srautas atkeliauja apgaubtas filtrais, kurių parametrus nustato pats failas. Nė vienas iš šių skaičių nėra jūsų. Juos pateikė tas, kas sukūrė failą – realiame darbe tai gali būti kliento sąskaita faktūra arba priedas iš nežinomo siuntėjo. Dekoderiai, kurie paverčia šiuos baitus pikseliais ir glifais, yra puolimo paviršius, o parseris, kuris aklai pasitiki savo įvestimi, yra per vieną sugadintą failą nuo lūžimo ar dar blogesnių pasekmių.
PDFlibPas praėjo saugumo stiprinimo etapą, kuriame visas dekodavimo kelias buvo traktuojamas kaip priešiškas – tai apėmė šriftų programas (TrueType, Type1, CFF ir CMap lenteles), vaizdo dekoderius (PNG, GIF, TIFF, JBIG2 bei CCITT Group 3 ir Group 4) ir srautų filtrus (LZW, ASCII85 bei Flate prediktorius). Toliau aprašomos penkios pašalintų defektų klasės, kurių kiekviena susijusi su konkrečiu Delphi elgesiu, padariusiu jas įmanomomis. Jos ištaisytos dabartinėse versijose, o panašios problemos kartojasi bet kuriame Pascal kode, kuris analizuoja nepatikimą įvestį.
Sveikojo skaičiaus perpildymas, pateikiantis per mažą buferį
Klasikinė atminties saugumo klaida vaizdų dekoderyje yra matmenų sandaugos persipildymas. Dekoderis nuskaito plotį, aukštį, komponentų skaičių ir bitų gylį, padaugina juos, kad nustatytų išvesties dydį, išskiria tiek baitų, o tada įrašo vaizdą tikraisiais jo matmenimis. Jei daugyba atliekama 32 bitų aritmetika, sandauga gali persipildyti iki mažos reikšmės, net jei kiekvienas faktorius atskirai yra priimtinas, todėl atminties išskyrimas pavyksta, tačiau buferis būna per mažas, o dekodavimas išlipa už jo ribų. Tai yra CWE-190 (sveikojo skaičiaus perpildymas), vedantis į CWE-787 (atminties rašymą už rėžių) vienu žingsniu vėliau.
Bendra vaizdų apdorojimo dalis jau ribojo kiekvieną matmenį iki 65535, tačiau atskiri dekoderiai ne visi paveldėjo šį apribojimą. Eilutės baitų ir aukščio sandauga, pavyzdžiui, ByteCount * FHeight, arba vieno pikselio išraiška FWidth * Components * BitDepth, Delphi aplinkoje yra 32 bitų sandauga, kai abu operandai yra 32 bitų sveikieji skaičiai, nepriklausomai nuo to, kokio pločio yra kintamasis, kuriam priskiriate rezultatą. 60000 plotis ir aukštis yra visiškai įmanomi dideliam nuskaitymui, tačiau jų sandauga baitais viršija 32 bitų su ženklu rėžius ir gaunamas mažas ilgis. Tie patys spąstai egzistavo ir ZLib prediktoriaus žingsnyje: BitsPerComponent * Colors * Columns.
Sprendimas – paversti bent vieną operandą Int64 tipo kintamuoju, kad visa išraiška būtų skaičiuojama 64 bitų tikslumu, o tada palyginti su MaxInt ir atmesti failą prieš sumažinant tipą ir iškviečiant SetLength.
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
Tai yra Delphi specifinė, o ne bendra problema dėl tylaus tipo susiaurinimo. Per didelės išraiškos priskyrimas 32 bitų tikslui yra leistina konversija, apie kurią kompiliatorius pagal nutylėjimą neįspėja, o diapazono tikrinimas (range checking) negaudo perpildymo, kuris įvyksta prieš naudojant reikšmę kaip indeksą. Jei paliksite sandaugą 32 bitų, kalba tyliai pateiks jums dydį, kuris meluoja apie tai, kiek atminties dekodavimas ruošiasi paliesti.
Lauko tipas, dėl kurio apsauga niekada negali suveikti
TIFF failas yra vaizdo failų katalogų (image file directories) grandinė, kurios kiekvienas elementas nurodo kito baitų poslinkį. Kenkėjiškas failas gali nukreipti šią grandinę atgal į save, o skaitytuvas, kuris eina per ją be stabdymo sąlygos, veiks amžinai. Tai yra CWE-835 – begalinis ciklas, valdomas užpuoliko įvesties, o apsauga yra skaitiklis, kuris sustoja, kai viršija ribą, kurios nepasiektų joks tikras failas.
Puslapių skaitiklis buvo deklaruotas kaip Word, kuris Delphi talpina reikšmes nuo 0 iki 65535. Cikle buvo nutraukimo sąlyga „sustoti, kai puslapių skaičius viršija 65535“, kas atrodo teisinga, kol nepastebite, kad operando ir slenksčio viršutinė riba sutampa. Word tipo kintamasis niekada negali būti didesnis už 65535, todėl palyginimas struktūriškai visada yra klaidingas: kai skaitiklis pasiekia 65535, sekantis padidinimas gražina jį į 0, apsauga niekada nemato reikšmės virš šios ribos, o besisukanti IFD grandinė verčia skaitytuvą veikti amžinai.
Sprendimas buvo išplėsti lauką, kad apsauga galėtų išreikšti reikšmę, kurią skaitiklis gali pasiekti. Deklaravus TPDFTIFF.FPageCount kaip Integer, tas pats palyginimas FPageCount > 65535 tampa pasiekiamas, ciklas sustabdomas, o vieša PageCount savybė pakeitė tipą, kad atitiktų reikalavimus, nepažeidžiant suderinamumo. Kai rėžių patikra turi formą Value > MaxValueOfType(Value) ir kintamasis jau yra to maksimalaus tipo, sąlyga visada yra klaidinga: išplėskite tipą arba lyginkite su maksimalia reikšme lygybės pagrindu.
Diapazono tikrinimas išjungtas kritiniame kelyje
Kai įjungtas diapazono tikrinimas, Delphi įterpia rėžių patikrą kiekvienam masyvo ir eilutės indeksui – tai yra skirtumas tarp to, ar neteisingas indeksas sukels pagaunamą ERangeError klaidą, ar tas pats indeksas tiesiog skaitys/rašys atmintį, kuri nepriklauso šiai struktūrai. Kritiniai vykdymo keliai (hot paths) kartais tai išjungia vietine direktyva {$R-}, kas yra pateisinama tik tol, kol indeksai yra visiškai patikimi.
Sąrašo prieigos funkcija, kuria remiasi šriftų interpretatoriai, TPDFlibStringList.Get, yra būtent toks kelias. Windows platformoje ji kompiliuojama su išjungtu rėžių tikrinimu ir indeksuoja atmintį tiesiogiai, todėl už ribų esantis indeksas sukelia ne klaidą, o tiesioginę prieigą prie atminties. Tai tinka, kai indeksas visada teisingas, bet netinka CFF arba Type2 charstring interpretatoriuje, kur indeksas gali ateiti iš failo. Jei charstring paima operandą iš tuščio dėklo, gaunamas indeksas -1; jei glifo identifikatorius nukrypsta vienetu, jis indeksuoja vieną vietą už pabaigos rėžių. Kai rėžių tikrinimas išjungtas, abu šie atvejai tampa tiesiogine prieiga prie atminties už ribų, o kadangi vietose saugomos nuorodų skaičiavimą naudojančios AnsiString reikšmės, toks skaitymas taip pat gali sugadinti eilutės nuorodų skaičių.
Apsaugos stiprinimas neįjungė rėžių tikrinimo atgal šiame kelyje. Vietoj to jis pirmiausia padarė indeksus patikrinamus: prieš paimdamas operando viršūnę, interpretatorius patikrina, ar dėklas nėra tuščias, o kiekviena indekso apsauga buvo parašyta griežtu mažiau-negu palyginimu, o ne mažiau-arba-lygu, kuris leistų nukrypti vienetu. Ši direktyva perkelia atsakomybę už rėžius nuo kompiliatoriaus jums, todėl pašalintas tikrinimas turi būti rankiniu būdu įdiegtas kiekviename įėjimo taške.
Begalinė rekursija charstring interpretatoriuje
Type2 charstring gali iškviesti subprogramą, o subprograma pati yra charstring, kuri gali iškviesti kitą, todėl vietiniai ir pasauliniai iškvietimo operatoriai leidžia failui nuspręsti, kokiu gyliu bus vykdomas nusileidimas. Subprograma, kuri iškviečia pati save tiesiogiai arba per ciklą, rekursiškai veikia be pabaigos, kol išsenka dėklas ir procesas nutrūksta. Tai yra CWE-674 (nekontroliuojama rekursija).
Type1 interpretatorius jau turėjo apsaugą nuo to. Jis turėjo iškvietimų gylio skaitiklį bei ribą PLType1MaxCallDepth ir atsisakydavo nusileisti žemiau jos, kas atspindi pačioje Type1 specifikacijoje nurodytą gylio limitą. Vėliau pridėtas ir struktūriškai panašus Type2 interpretatorius neturėjo šios apsaugos, todėl rankiniu būdu sukurtas šriftas su subprograma, kuri iškviečia pati save, tiesiogiai sukeldavo dėklo perpildymą (stack overflow).
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
Sprendimas buvo suteikti Type2 keliui tą patį ribotą gylį, kurį jau turėjo Type1. Bet koks rekursinis nusileidimas per užpuoliko valdomą struktūrą – ar tai būtų šrifto subprogramos, lizdinis masyvas, ir kryžminių nuorodų grandinė – reikalauja gylio lubų, kurių įvestis negalėtų pakeisti.
Neinicializuota atmintis, nutekanti į išvestį
Subtiliausias defektas leido krūvos (heap) turiniui nutekėti į dešifruotą išvestį, o priežastis yra SetLength savybė, kurią lengva pamiršti. Kai padidinate AnsiString dydį naudodami SetLength, Delphi priskiria baitus, bet jų neišvalo (neįrašo nulių), todėl naujas regionas išlaiko tai, kas anksčiau buvo toje atminties vietoje. Jei vėliau įrašomas kiekvienas baitas, tai neturi reikšmės; tačiau jei kelias palieka dalį buferio neįrašytą ir grąžina jį kaip duomenis, šie pasenę baitai išeina kartu su rezultatu. Tai yra CWE-457 (neinicializuotos atminties naudojimas), kuris, kirtęs pasitikėjimo ribą, tampa informacijos nutekėjimu.
AES-CBC dešifravimo kelias susidūrė būtent su tuo. Išvesties buferio dydis buvo nustatomas per SetLength, o dešifratorius apdorodavo šifruotą tekstą po vieną 16 baitų bloką. Kai šifruoto teksto ilgis nebuvo dalus iš 16 (tokį ilgį gali parinkti užpuolikas), paskutinis dalinis blokas niekada nebuvo įrašomas, todėl šie paskutiniai baitai išlaikė krūvos turinį, kurį paliko SetLength, o buferis buvo grąžinamas kaip dešifruotas dokumento objekto tekstas. Sprendimas yra dvi apsaugos priemonės, ir nė vienos iš jų atskirai nepakanka: dešifravimo įėjimo taškas dabar atmeta bet kokį šifruotą tekstą, kurio ilgis nėra dalus iš bloko dydžio, o išvestis prieš naudojimą papildomai išvaloma su FillChar, kad bet koks kelias, kuriam nepavyko įrašyti regiono, grąžintų nulius, o ne krūvos likučius.
Audito rezultatai
Šie penki defektai yra skirtingos klaidos, tačiau jos panašios. Sveikojo skaičiaus plotis, kuris persipildo, lauko tipas, kuris paverčia apsaugą nuolatine klaidinga sąlyga, diapazono tikrinimas, išjungtas ten, kur indeksai tapo nesaugūs, rekursija be pabaigos ir buferis, kurio kalba neišvalė. Kiekvienu atveju Delphi atliko tiksliai tai, kas joje apibrėžta, nes kalba suteikia perpildomą aritmetiką, tylų susiaurinimą, išjungiamą diapazonų tikrinimą, rekursiją be integruoto limito bei atminties išskyrimą be inicializacijos. Tai yra sutartis, ir Pascal parseris ją vykdo rankiniu būdu valdydamas keturis dalykus kiekvienoje failo kontroliuojamoje riboje: sveikojo skaičiaus plotį, rėžių tikrinimą, rekursijos gylį bei buferio inicializavimą.
Šie defektai yra ištaisyti naujausiose PDFlibPas versijose, skirtose Delphi ir C++Builder. Jei jūsų darbas taip pat susijęs su failų apsaugos analize, susijusios pastabos apie šifravimo ir teisių auditą bei PDF/A ir PDF/UA išankstinį patikrinimą (preflight) aprašo to paties parserio analizės pusę. Visa tai yra platinama PDFlibPas Delphi PDF Library sudėtyje kartu su įkėlimo, vaizdavimo ir pasirašymo API, aprašytais kitose šio tinklaraščio dalyse.