PDF ei ole dokumentti, jonka avaat. Se on pieni ohjelma, jonka ajat. Jokainen upotettu fontti on pinopohjainen tulkki, joka odottaa merkkijonoja (charstring), jokainen kuva on dekooderi, jolle syötetään tiedoston valitsemat leveys-, korkeus- ja bittisyyskentät, ja jokainen virta saapuu käärittynä suodattimiin, joiden parametrit tiedosto asetti. Mikään näistä luvuista ei ole sinun. Ne ovat peräisin siltä, joka tiedoston loi, mikä todellisessa työssä on asiakkaan lasku tai liite tuntemattomalta lähettäjältä. Dekooderit, jotka muuttavat nämä tavut pikseleiksi ja glyyfeiksi, ovat hyökkäyspinta, ja jäsennin, joka luottaa syötteeseensä siellä, on yhden viallisen tiedoston päässä kaatumisesta tai pahemmasta.
PDFlibPas kävi läpi karkaisuvaiheen, jossa koko purkupolku kohdattiin vihamielisenä - aina fonttiohjelmista (TrueType, Type1, CFF ja CMap-taulukot) kuvadekoodereihin (PNG, GIF, TIFF, JBIG2 ja CCITT Group 3 ja Group 4) sekä virtasuodattimiin (LZW, ASCII85 ja Flate-ennustajat). Seuraavassa on viisi vikaluokkaa, jotka se sulki, ja kukin niistä perustuu tiettyyn Delphi-käyttäytymiseen, joka teki sen mahdolliseksi. Ne on korjattu nykyisissä julkaisuissa, ja samat muodot toistuvat kaikissa Pascal-koodeissa, jotka jäsentävät epäluotettavaa syötettä.
Kokonaisluvun ylivuoto, joka antaa sinulle alimitoitetun puskurin
Kuvadekooderin klassinen muistiturvallisuusvirhe on mittojen tulo (dimension product), joka kiertyy (wrap). Dekooderi lukee leveyden, korkeuden, komponenttien määrän ja bittisyyden, kertoo ne keskenään mitoittaakseen tulosteensa, varaa kyseisen määrän tavuja ja kirjoittaa sitten kuvan sen todellisilla mitoilla. Jos kertolasku tehdään 32-bittisellä aritmetiikalla, tulo voi kiertyä pieneksi arvoksi silloinkin, kun jokainen yksittäinen tekijä on järkevällä alueella, joten varaus onnistuu mutta jää aivan liian pieneksi, ja purkutoiminto kävelee sen lopun ohi. Kyseessä on CWE-190 (kokonaisluvun ylivuoto), joka johtaa yhtä vaihetta myöhemmin kekomuistin rajojen ulkopuoliseen kirjoitukseen (CWE-787).
Jaettu kuvapolku rajoitti jo kunkin mitan arvoon 65535; erilliset dekooderit eivät kaikki perineet tätä rajoitusta. Rivitavut-kertaa-korkeus-lauseke, kuten ByteCount * FHeight, tai pikselikohtainen lauseke, kuten FWidth * Components * BitDepth, on Delphissä 32-bittinen tulo, kun molemmat operandit ovat 32-bittisiä kokonaislukuja, riippumatta siitä, kuinka leveä muuttuja on, johon tulos sijoitetaan. 60000:n leveys ja korkeus ovat molemmat uskottavia suurelle skannaukselle, mutta niiden tulo tavuina ylittää etumerkillisen 32-bittisen alueen ja pituus palautuu pienenä. Sama ansa eli ZLib-ennustajan askeleessa (stride) BitsPerComponent * Colors * Columns.
Korjaus on tehdä vähintään yhdestä operandista Int64, jotta koko lauseke arvioidaan 64-bittisenä, ja verrata sitten MaxInt-arvoon ja hylätä tiedosto ennen kaventamista takaisin SetLength-metodille.
// 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);
Se, mikä tekee tästä Delphi-ongelman yleisen sijaan, on hiljainen kaventuminen. Liian leveän lausekkeen sijoittaminen 32-bittiseen kohteeseen on laillinen muunnos, josta kääntäjä ei varoita oletusarvoisesti, eikä aluetarkistus ota kiinni kiertymistä, joka tapahtuu ennen kuin arvoa koskaan käytetään indeksinä. Jätä tulo 32 bittiin, ja kieli antaa hiljaa pituuden, joka valehtelee siitä, kuinka paljon muistia purkutoiminto on koskettamassa.
Kenttätyyppi, joka tekee suojauksen laukeamisen mahdottomaksi
TIFF-tiedosto on kuvatiedostohakemistojen (IFD) ketju, joista kukin kantaa seuraavan tavusiirtymää. Haitallinen tiedosto voi osoittaa ketjun takaisin itseensä, ja lukija, joka käy sitä läpi ilman lopetusehtoa, ajaa ikuisesti. Kyseessä on CWE-835 (hyökkääjän hallitseman syötteen ajama ikuinen silmukka), ja puolustus on laskuri, joka pysähtyy, kun se ylittää rajan, jota mikään laillinen tiedosto ei saavuttaisi.
Sivulaskuri oli esitelty Word-tyyppinä, joka Delphissä pitää sisällään arvot 0-65535. Silmukka kantoi lopetussuojaa muotoa "pysäytä, kun sivumäärä ylittää 65535", mikä näyttää oikealta, kunnes huomaat operandin ja kynnyksen jakavan saman ylärajan. Word ei voi koskaan olla suurempi kuin 65535, joten vertailu on rakenteellisesti aina epätosi: kun laskuri saavuttaa arvon 65535, seuraava lisäys kiertää sen takaisin arvoon 0, suoja ei koskaan näe katon ylittävää arvoa, ja silmukkaava IFD-ketju pitää lukijan pyörimässä.
Korjaus oli laajentaa kenttää, jotta suoja voi ilmaista arvon, jonka laskuri voi todella saavuttaa. Kun TPDFTIFF.FPageCount on esitelty Integer-tyyppinä, sama FPageCount > 65535 -vertailu tulee saavutettavaksi, silmukka päättyy, ja julkinen PageCount-ominaisuus muutti tyyppiään vastaamaan sitä rikkomatta mitään kutsujaa. Aina kun rajatarkistuksella on muoto Value > MaxValueOfType(Value) ja operandilla on jo tyyppinä täsmälleen kyseinen maksimi, ehto on vakio epätosi: laajenna tyyppiä tai testaa yhtäsuuruutta maksimia vasten, jotta se voi laueta.
Aluetarkistus poistettu käytöstä kriittisellä polulla
Aluetarkistuksen ollessa päällä Delphi lisää rajatarkistuksen jokaiseen taulukon ja merkkijonon indeksiin, mikä on ero alueen ulkopuolisen indeksin nostaman napattavan ERangeError-poikkeuksen ja sen välillä, että sama indeksi lukee tai kirjoittaa muistia, joka ei kuulu rakenteeseen. Kriittiset polut poistavat sen joskus käytöstä paikallisella {$R-}-direktiivillä, mikä on puolustettavissa aina siihen asti, kunnes indeksit lakkaavat olemasta luotettavia.
Fonttitulkkien nojaama luettelon aksessori, TPDFlibStringList.Get, on juuri tällainen polku. Windowsissa se käännetään aluetarkistus pois päältä ja se indeksöi taustamuistinsa suoraan, joten alueen ulkopuolella oleva indeksi ei ole virhe vaan raaka muistin käyttö. Se on hienoa silloin, kun indeksi on aina kelvollinen, ja lakkaa olemasta hienoa CFF- tai Type2-merkkijonotulkin sisällä, jossa indeksi voi tulla tiedostosta. Merkkijono (charstring), joka ponnauttaa operandin tyhjästä pinosta, tuottaa indeksin miinus yksi; glyyfitunniste, joka on yhden verran pielessä glyyfimäärään nähden, indeksöi yhden paikan lopun ohi. Aluetarkistuksen ollessa pois päältä molemmista tulee aito rajojen ulkopuolinen käyttö napatun poikkeuksen sijaan, ja koska paikat sisältävät viitelaskettuja AnsiString-arvoja, harhalyönti voi myös korruptoida merkkijonon viitelaskurin.
Karkaisu ei kytkenyt aluetarkistusta takaisin päälle kriittiselle polulle. Se teki indekseistä ensin todistettavasti kelvollisia: ennen operandin ottamista pinon päältä tulkki tarkistaa, ettei pino ole tyhjä, ja jokainen indeksisuoja kirjoitettiin tiukasti pienemmäksi kuin määrä sen sijaan, että käytettäisiin pienempi-tai-yhtä-suuri-ehtoa, joka sallii yhden yli. Direktiivi siirtää vastuun rajoista kääntäjältä sinulle, ja sen poistama validointi on asetettava käsin takaisin jokaisessa aloituspisteessä.
Rajoittamaton rekursio merkkijonotulkissa
Type2-merkkijono (charstring) voi kutsua aliohjelmaa, ja aliohjelma on itse merkkijono, joka voi kutsua toista, joten paikalliset ja globaalit aliohjelmakutsutoiminnot antavat tiedoston päättää, kuinka syvälle se menee. Aliohjelma, joka kutsuu itseään, suoraan tai syklin kautta, rekursioituu ilman loppua, kunnes natiivipino on kulutettu loppuun ja prosessi kuolee. Kyseessä on CWE-674 (hallitsematon rekursio).
Type1-tulkki suojautui jo tätä vastaan. Se kantoi kutsusyvyyden laskuria ja kattoa PLType1MaxCallDepth, ja kieltäytyi laskeutumasta sen ohi, mikä heijastaa Type1-määrityksen itsensä nimeämää syvyysrajana. Myöhemmin lisätty ja rakenteellisesti samanlainen Type2-tulkki ei kantanut samaa suojaa, ja käsin rakennettu fontti aliohjelmalla, joka kutsuu omaa numeroaan, kävelee suoraan puuttuvan tarkistuksen läpi pinon ylivuotoon.
// 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
Korjaus oli antaa Type2-polulle sama rajoitettu syvyys, joka sen Type1-sisarella jo oli. Mikä tahansa rekursiivinen laskeutuminen hyökkääjän hallitseman rakenteen yli, olivatpa kyseessä fonttialiohjelmat, sisäkkäinen taulukko tai ristiviiteketju, tarvitsee syvyyskaton, jota syöte ei voi nostaa.
Alustamaton muisti, joka vuotaa tulosteeseen
Kaikkein hienovaraisin vika vuosi kekomuistin sisältöä salauksen purettuun tulosteeseen, ja syynä on SetLength-metodin ominaisuus, joka on helppo unohtaa. Kun kasvatat AnsiString-merkkijonoa SetLength-metodilla, Delphi varaa tavut mutta ei nollaa niitä, joten uusi alue pitää sisällään sen, mitä kyseisessä kekomuistissa aiemmin oli. Jos jokainen tavu kirjoitetaan myöhemmin, sillä ei ole koskaan merkitystä; jos polku jättää osan puskurista kirjoittamatta ja palauttaa sen sitten tietona, nuo vanhentuneet tavut kulkevat tuloksen mukana. Kyseessä on CWE-457 (alustamattoman muistin käyttö), ja kun tulos ylittää luottamusrajan, siitä tulee tietovuoto.
AES-CBC-salauksenpurkupolku osui juuri tähän. Tulosinfopuskuri mitoitettiin SetLength-metodilla ja purkaja käsitteli salatekstiä yksi 16-tavuinen lohko kerrallaan. Kun salatekstin pituus ei ollut 16:n monikerta - pituus, jonka hyökkääjä voi valita - perässä olevaa osittaista lohkoa ei koskaan kirjoitettu, joten nuo viimeiset tavut säilyttivät kekomuistin sisällön, jonka SetLength jätti jälkeensä, ja puskuri palautettiin dokumenttiolion purettuna selkokielenä. Korjaustoimenpide on kaksi suojaa, eikä kumpikaan yksin riitä: salauksenpurun aloituspiste hylkää nyt minkä tahansa salatekstin, jonka pituus ei ole lohkokoon monikerta, ja takalukkona tulos nollataan FillChar-metodilla ennen käyttöä, joten mikä tahansa polku, joka epäonnistuu alueen kirjoittamisessa, palauttaa nollia kekomuistin jäänteiden sijaan.
Mitä tästä vaiheesta jää käteen
Viisi vikaa ovat eri virheitä, mutta ne rimmaavat. Kokonaisluvun leveys, joka kiertää tulon, kenttätyyppi, joka kiinnittää suojan vakioksi epätodeksi, aluetarkistus poistettu käytöstä, jossa indeksit lakkasivat olemasta turvallisia, rekursio ilman lattiaa ja puskuri, jota kieli kieltäytyi nollaamasta. Jokaisessa niistä Delphi teki täsmälleen sen, mitä se määrittää, koska kieli antaa sinulle kiertyvää aritmetiikkaa, hiljaista kaventumista, aluetarkistuksia, jotka voit kytkeä pois päältä, rekursiota ilman sisäänrakennettua rajaa ja varausta, joka ei alusta. Tämä on sopimus, ja Pascal-jäsennin täyttää sen omistamalla käsin neljä asiaa jokaisella rajalla, jota tiedosto hallitsee: kokonaisluvun leveyden, aluetarkistuksen, rekursiosyvyyden ja puskurin alustuksen.
These defects are closed in current PDFlibPas releases, the engine for Delphi and C++Builder. If your work also reaches into how a file claims to be protected, the companion notes on auditing encryption and permissions and on PDF/A and PDF/UA preflight cover the analysis side of the same parser, and all of it ships inside the PDFlibPas Delphi PDF Library alongside the loading, rendering, and signing APIs covered elsewhere on this blog.