Dalinių eilučių paieška, pavyzdžiui, „Pos('/Length', Dict)“, yra netinkamas įrankis PDF žodynui skaityti, kadangi PDF vardų raktai (name keys) dalijasi bendrais priešdėliais (prefixes): „/Length“ yra „/Length1“ priešdėlis, o „/Encrypt“ yra „/EncryptMetadata“ priešdėlis. Standarto ISO 32000-1 §7.3.5 dalis apibrėžia vardą kaip atpažinimo raktą (token), kuris baigiasi tik skirtuku arba tuščiu tarpu, todėl raktas laikomas rastu tik tada, kai baitas po jo yra vienas iš šių simbolių. Žodyno skaitytuvas, kuris praleidžia šį patikrinimą, anksčiau ar vėliau nuskaitys neteisingą reikšmę iš visiškai teisingo failo
Klaida, kuri mus to išmokė, visai neatrodė kaip leksinio analizatoriaus problema. Atitikties patikra pradėjo pranešti, kad šrifto failo „FontFile“ srautas yra pažeistas: išspausta šrifto programa buvo tik fragmentas, nukirstas lentelės viduryje. Failas atsidarė puikiai bet kurioje peržiūros programoje. Srauto duomenys diske buvo nepažeisti. Pagrindinė priežastis slypėjo vienoje mūsų bendro žodyno skaitytuvo eilutėje: „Pos('/Length', ...)“ atitiko „/Length1“ — standartinį raktą, kurį šrifto failo srauto žodynai turi pagal ISO 32000-1 127 lentelę, ir skaitytuvas paėmė sveikąjį skaičių po „/Length1“ kaip srauto ilgį. To konkretaus failo kūrėjas įrašė „/Length1“ prieš „/Length“ (tai daryti leidžiama, nes žodyno įrašai nėra surikiuoti pagal §7.3.7). Srautas buvo nukirptas iki klaidingo baitų skaičiaus, o visos tolesnės patikros, kurios jį naudojo, tyliai nustojo veikti
Kodėl dalinių eilučių paieška sugadina PDF žodynų analizavimą?
Dalinių eilučių paieška sugenda, nes PDF vardų erdvė yra pilna sąmoningai parinktų bendrų priešdėlių šeimų, o žodyno įrašų tvarka nėra apibrėžta. Standarto ISO 32000-1 127 lentelė apibrėžia „/Length1“, „/Length2“ bei „/Length3“ įterptiems šriftų srautams, visiems esant greta „/Length“ tame pačiame žodyne. Šifravimo žodynas suporuoja „/Encrypt“ faile su „/EncryptMetadata“ jo viduje. Su trumpais raktais yra dar blogiau: paieška, sukurta kaip „Pos('/' + Key, ...)“, kai „Key = 'N'“, sėkmingai randa „/Name“ arba „/Nums“. Nė vienam iš šių susidūrimų nereikia sugadinto failo. Failo kūrėjas, įrašantis „/Length1“ prieš „/Length“, visiškai atitinka reikalavimus, o tai reiškia, kad dalinės eilutės klaida yra teisingumo klaida apdorojant visiškai tvarkingą failą, o ne atsparumo problema apdorojant sugadintus failus
Ši klaida taip pat yra tyli. Neteisingas „/Length“ nesukelia išimties — jis tiesiog perduoda trumpesnį arba ilgesnį baitų rinkinį, nei srautas iš tikrųjų užima. Jei šis rinkinys perduodamas šriftų patikrai, „CMap“ analizei ar metaduomenų nuskaitymui, gavėjas mato šiukšles ir paprastai nieko nepraneša, nes pusė „zlib“ srauto tiesiog neišsipakuoja ir kodas eina toliau. Mes patys turėjome būtent šios klasės defektą ir pataisėme jį savo bendro skaitytuvo v2.14.3 versijoje, kai po kiekvieno ISO 32000-1 §7.2–§7.3 punkto audito pažymėjome kiekvieną „Pos“ stiliaus paiešką kaip įtartiną
Ką iš tikrųjų apibrėžia ISO 32000-1 §7.3.5 punktas
Skyrius 7.3.5 yra trumpas ir tikslus: vardo objektas yra pasviręs brūkšnys (solidus), po kurio seka įprastų simbolių seka, ir šis elementas (token) užbaigiamas pirmuoju skirtuku arba tuščiu tarpu. Skirtukai yra aštuoni skliaustelių simboliai bei pasviręs brūkšnys ir procento ženklas — „( ) < > [ ] { } / %“ —, o tušti tarpai yra nulinis simbolis, tabuliacija, eilutės perkėlimas, puslapio perkėlimas, karietėlės grąžinimas ir tarpas (§7.2.2–§7.2.3). Ši užbaigimo taisyklė yra viskas. „/Length1“ nėra „/Length, po kurio seka 1“; tai yra vienas, nedalomas elementas, lygiai taip pat, kaip „LengthOne“ ir „Length“ yra skirtingi identifikatoriai „Pascal“ kalboje. Bet koks skaitytuvas, ieškantis raktų pagal paprastą baitų paiešką, iš naujo kuria leksinį analizatorių su ištrinta pabaigos taisykle
Štai supaprastintas defekto pavyzdys. Ši versija kompiliuojasi, sėkmingai praeina testus su failais, kuriuose įrašyta „/Length“ pirmoje vietoje, ir sugadina srautus failuose, kur taip nėra
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Pilnas atpažinimo rakto atitikimas: baito po rakto patikrinimas
Teisinga taisyklė išplaukia tiesiai iš §7.3.5: rastas atitikmuo yra tikras raktas tik tada, kai simbolis iškart po jo yra skirtukas, tuščias tarpas arba buferio pabaiga. Visa kita yra ilgesnis vardas, kuris tik dalijasi tuo pačiu priešdėliu, todėl paieška turi būti tęsiama toliau. Pataisymas mūsų skaitytuve pakeitė kiekvieną paprastą „Pos“ paiešką viena bendra funkcija, pagrįsta šia taisykle
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
Dvi detalės šiame cikle yra svarbios. Pirma, ciklas ieško toliau, užuot pranešęs apie klaidą per pirmąjį priešdėlio susidūrimą, kadangi „/Length1 120 /Length 4076“ yra visiškai leistina tvarka ir tikrasis raktas yra toliau. Antra, buferio pabaigos atvejis laikomas pabaigos ženklu, nes žodyno fragmentas gali legaliai baigtis iškart po vardo. Subtilesnis dalykas, kurį verta patikrinti savo kode: ta pati taisyklė galioja ir kairėje atitikmens pusėje, jei jūsų paieškos eilutė neturi pasviro brūkšnio, kitaip „Pos('Length', ...)“ gali pataikyti į „/PieceLength“. Paieškos eilutės pradžios fiksavimas pasviru brūkšniu „/“, kaip parodyta aukščiau, išsprendžia kairiojo krašto problemą, nes „/“ pats savaime yra skirtukas, užbaigiantis ankstesnį elementą
Kaip kenksmingas PDF failas gali paversti analizatoriaus klaidą gigabaitų atminties išskyrimu?
Sugadintas arba kenksmingas failas paverčia šias leksines klaidas išteklių išnaudojimu, kadangi žodyno sveikieji skaičiai dažnai nurodo atminties išskyrimo dydžius. Mūsų auditas rado būtent tokios formos grandinę objektų srauto išplėtime. Įrašas „/N“ srauto „ObjStm“ žodyne nurodo, kiek suspaustų objektų yra sraute, ir išpakavimo kodas kvietė „SetLength“ masyve, kurio dydis nurodytas šia reikšme. Tačiau skaičių analizatorius nesėkmės atveju palikdavo nepakeistą išvesties parametrą (out-parameter), vis tiek jį grąžindamas — todėl neskaitinis „/N“ perdavė „SetLength“ funkcijai neinicijuotą reikšmę iš steko. Atsitiktinis teigiamas skaičius ten reiškia prašymą išskirti gigabaitus atminties, sukeltą vos kelių sugadintų baitų įvesties, vos tik nuskaitant dokumentą, kuriuo dar net nespėjote pasitikėti
Pataisymas turėjo dvi nepriklausomas dalis, ir abi jos yra pritaikomos plačiau. Analizatorius dabar grąžina aiškų 0 nesėkmės atveju, o ne neinicijuotą atmintį. Ir gavėjas nebepasitiki reikšme „/N“ be aritmetinio patikrinimo: srauto antraštės sritis prieš „/First“ saugo du skaičius — objekto numerį ir poslinkį — kiekvienam suspaustam objektui, ir kiekviena pora užima bent keturis baitus su skirtukais. Todėl bet koks „/N“, viršijantis „FirstVal div 4 + 1“, yra fiziškai neįmanomas pagal nurodytą antraštės dydį ir yra atmetamas prieš bet kokį atminties išskyrimą. Šis patikrinimas kainuoja tik vieną palyginimą ir yra suformuotas iš jau turimų duomenų — tai yra šablonas, kurio reikia ieškoti: riba, kurią įrodo pats failas, o ne atsitiktinė konstanta
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
Dar dvi ribos užbaigia apsaugos perimetrą mūsų skaitytuve (abi įdiegtos v2.12.0 versijoje). Srauto skaitytuvas atsisako priimti bet kokį „/Length“, didesnį už patį failą, prieš išskirdamas buferį rezultatui — srautas negali būti didesnis už konteinerį, kuriame jis gyvena, todėl šis tikrinimas neturi klaidingų teigiamų rezultatų. O išpakavimo kelias riboja išpakuotų duomenų kiekį iki 256 MiB, kas apsaugo nuo klasikinės „zlib bombos“, kai keli kilobaitai duomenų išsipučia be ribų; ši riba yra pakankama bet kokiam realiam PDF srautui, kartu apsaugant sistemą blogiausiu atveju. Visų trijų taisyklių esmė ta pati: kiekvienas failo nurodytas dydis yra tik teiginys, ir analizatorius patikrina šį teiginį pagal tai, ką gali išmatuoti, prieš skirdamas jam atmintį. Ta pati audito pozicija taikoma ir vienu lygiu žemiau ties sąsajos riba, kas aprašyta PDFium ABI ir atminties saugumo Delphi aplinkoje sustiprinime
Kur vien tik pilno atpažinimo rakto taisyklės nepakanka
Sąžiningos ribos, kad per daug nepasitikėtumėte aukščiau nurodyta funkcija. Pilno elemento atitikimas sutvarko rakto atpažinimą, tačiau paprasta baitų paieška žodyno srityje vis tiek negali nustatyti, ar atitikmuo yra įdėtiniame žodyne, tekstinėje reikšmėje ar komentare — „FindDictKey“ puslapio objekte gali pataikyti į raktą „/Resources“ požodyne, jei nurodysite per plačią sritį. Mūsų skaitytuvas pirmiausia apriboja paieškos sritį iki vieno objekto kūno ir traktuoja tekstus bei komentarus kaip atskirą audito sritį. Dalinių eilučių saugumas yra tik vienas laiptelis, o ne visi laiptai: kryžminių nuorodų patikra yra atskira sritis, aprašyta objektų ir kryžminių nuorodų (xref) srautų tikrinime, o platesnis grėsmių sąrašas ne jūsų pačių kurtiems dokumentams pateikiamas PDF saugumo rizikų audite
Jei palaikote ranka parašytą žodynų skaitytuvą Delphi ar Lazarus aplinkoje, kontrolinis sąrašas po šio incidento yra trumpas. Suraskite visus „Pos('/“ atvejus kode ir nukreipkite juos per pilno elemento atpažinimo funkciją. Sudarykite priešdėlių šeimų, kuriose dalyvauja jūsų raktai, sąrašą — „/Length“, „/Encrypt“, „/N“, „/Type“ (prieš „/Type1“) atsiranda realiuose failuose. Tada patikrinkite kiekvieną sveikąjį skaičių, kuris pasiekia „SetLength“, „GetMem“ ar kopijavimo ciklą, ir paklauskite, kas jį riboja: failo dydis, antraštės riba ar niekas. Čia aprašytas analizavimo sluoksnis yra mūsų „PDFium Component“ pagrindas, kuriame baitų lygio skaitytuvas ir atvaizdavimo variklis patikrina vienas kitą kiekviename dokumente, kurį jie paliečia