Tekninen artikkeli

Pascal PDF -jäsentäjän (parser) kovettaminen haitallisia tiedostoja vastaan

PDF ei ole vain asiakirja, jonka avaat. Se on pieni ohjelma, jota suoritat. Jokainen upotettu fontti on pinopohjainen (stack-based) tulkki, joka odottaa merkkijonoja (charstrings), jokainen kuva on dekooderi, jolle syötetään tiedoston valitsemat leveys-, korkeus- ja bittisyyskentät, ja jokainen tietovirta (stream) saapuu käärittynä suodattimiin, joiden parametrit tiedosto asetti. Yksikään noista numeroista ei ole sinun. Ne tulivat siltä, joka tuotti tiedoston, mikä todellisessa työkuormassa on asiakkaan lasku tai liite tuntemattomalta lähettäjältä. Dekooderit, jotka muuttavat nuo tavut pikseleiksi ja glyyfeiksi (glyphs), ovat hyökkäyspinta, ja jäsentäjä (parser), joka luottaa syötteeseensä siellä, on yhden väärin muotoillun tiedoston päässä kaatumisesta tai jostain pahemmasta

PDFlibPas kävi läpi kovettamisvaiheen (hardening pass), joka käsitteli koko dekoodauspolkua vihamielisenä (hostile), aina fonttiohjelmista (TrueType, Type1, CFF ja CMap-taulukot) kuvadekoodereihin (PNG, GIF, TIFF, JBIG2 sekä CCITT Group 3 ja Group 4) ja tietovirtasuodattimiin (LZW, ASCII85 ja Flate-ennustajat). Seuraavassa on viisi sen sulkemaa vikaluokkaa, joista jokainen perustuu siihen tiettyyn Delphi-käyttäytymiseen, joka teki sen mahdolliseksi. Ne on korjattu nykyisissä julkaisuissa, ja samat muodot toistuvat missä tahansa Pascal-koodissa, joka jäsentää (parses) epäluotettavaa syötettä

Kokonaisluvun ylivuoto (integer overflow), joka antaa sinulle alimittaisen puskurin

Klassinen muistiturvallisuuden (memory-safety) bugi kuvadekooderissa on dimensiotulo, joka kietoutuu (wraps) ympäri. Dekooderi lukee leveyden, korkeuden, komponenttimäärän ja bittisyvyyden, kertoo ne mitoittaakseen tulosteensa, varaa niin monta tavua ja kirjoittaa sitten kuvan sen todellisilla mitoilla. Jos kertolasku tehdään 32-bittisessä aritmetiikassa, tulo voi kietoutua (wrap) pieneen arvoon, vaikka jokainen yksittäinen tekijä olisi järkevällä alueella, joten varaus (allocation) onnistuu, tulee ulos aivan liian pienenä, ja dekoodaus kävelee pois sen päästä. Tämä on CWE-190, kokonaisluvun ylivuoto (integer overflow), joka johtaa keon (heap) rajojen ulkopuoliseen kirjoitukseen (out-of-bounds write, CWE-787) yhden askeleen myöhemmin

Jaettu kuvapolku (shared image path) rajoitti jo jokaisen ulottuvuuden (dimension) arvoon 65535; erilliset (standalone) dekooderit eivät kaikki perineet tätä rajoitusta. Rivin tavut kertaa korkeus -lauseke (row-bytes-times-height), kuten ByteCount * FHeight, tai pikselikohtainen lauseke (per-pixel expression), kuten FWidth * Components * BitDepth, on 32-bittinen tulo Delphissä, kun molemmat operandit ovat 32-bittisiä kokonaislukuja, riippumatta siitä, kuinka leveä on muuttuja, johon sijoitat tuloksen. Leveys ja korkeus 60000 ovat molemmat mahdollisia (plausible) suurelle skannaukselle, mutta niiden tulo tavuina ylittää etumerkillisen (signed) 32-bittisen alueen, ja pituus tulee ulos pienenä. Sama ansa eli ZLib-ennustajan harppauksessa (stride), BitsPerComponent * Colors * Columns

Korjaus on tehdä ainakin toisesta operandista Int64, jolloin koko lauseke arvioidaan 64-bittisenä, sitten verrata sitä arvoon MaxInt ja hylätä tiedosto ennen kuin se kavennetaan takaisin alas kutsumaan SetLength:iä

// 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 eikä yleisen ongelman, on hiljainen kaventaminen (silent narrowing). Liian leveän lausekkeen sijoittaminen 32-bittiseen kohteeseen on laillinen muunnos, josta kääntäjä (compiler) ei varoita oletuksena, eikä alueen tarkistus (range checking) saa kiinni ympärikietoutumista (wrap), joka tapahtuu ennen kuin arvoa koskaan käytetään indeksinä. Jätä tulo 32-bittiseksi, ja kieli antaa sinulle hiljaa pituuden, joka valehtelee siitä, kuinka paljon muistia dekoodaus on aikeissa koskettaa

Kenttätyyppi (field type), joka tekee suojasta (guard) mahdottoman laukaista

TIFF-tiedosto on kuvatiedostohakemistojen (image file directories) ketju, joista jokainen kantaa seuraavan tavu-siirtymää (byte offset). Haitallinen tiedosto voi ohjata tuon ketjun takaisin itseensä, ja lukija, joka käy sen läpi ilman pysäytysolosuhdetta (stop condition), jatkaa toimintaansa ikuisesti. Tuo on CWE-835, hyökkääjän hallitseman syötteen ajama loputon silmukka (infinite loop), ja puolustuksena on laskuri (counter), joka pysähtyy, kun se ohittaa rajan, jota yksikään laillinen tiedosto ei saavuttaisi

Sivulaskuri oli ilmoitettu tyypiksi Word, joka Delphissä pitää sisällään arvot 0 - 65535. Silmukassa (loop) oli lopetussuoja (termination guard) muodossa "pysähdy, kun sivumäärä ylittää 65535", joka näyttää oikealta, kunnes huomaat, että operandilla ja kynnyksellä on sama yläraja (upper bound). Word ei voi koskaan olla suurempi kuin 65535, joten vertailu on rakenteellisesti aina epätosi (false): kun laskuri saavuttaa arvon 65535, seuraava lisäys (increment) kietoo sen (wraps it) takaisin nollaan, suoja ei koskaan näe arvoa katon yläpuolella, ja luuppaava IFD-ketju (looping IFD chain) pitää lukijan pyörimässä

Korjaus oli leventää kenttää (widen the field), jotta suoja pystyy ilmaisemaan arvon, jota laskuri voi todellisuudessa pitää hallussaan. Kun TPDFTIFF.FPageCount on julistettu Integer-tyyppiseksi, sama vertailu FPageCount > 65535 tulee saavutettavaksi, silmukka (loop) päättyy, ja julkinen PageCount-ominaisuus muutti tyyppiään vastaamaan tätä rikkomatta mitään kutsujaa. Aina kun rajojen tarkistuksella on muoto Value > MaxValueOfType(Value) ja operandi on jo tyypitetty (typed) täsmälleen siihen maksimiin, ehto on vakio epätosi (constant false): levennä tyyppiä, tai testaa yhtäsuuruutta maksimia vastaan, jotta se voi laueta

Aluetarkistus (range checking) kytketty pois päältä kuumalla polulla (hot path)

Aluetarkistuksen ollessa päällä, Delphi lisää rajojen tarkistuksen (bounds check) jokaiseen taulukon (array) ja merkkijonon (string) indeksiin, mikä on ero sen välillä, nostaako alueen ulkopuolella (out-of-range) oleva indeksi kiinniotettavan (catchable) ERangeError-virheen vai lukeeko tai kirjoittaako sama indeksi muistia, joka ei kuulu rakenteelle. Kuumat polut ottavat sen joskus pois käytöstä paikallisella {$R-}-direktiivillä, mikä on puolustettavissa juuri siihen asti, kunnes indekseihin ei voi enää luottaa

Listan käyttäjä (accessor), johon fonttitulkit nojaavat, TPDFlibStringList.Get, on juuri tällainen polku. Windowsissa se käännetään ilman aluetarkistusta (range checking off) ja se indeksoi tausta-asemaansa (backing store) suoraan, joten alueen ulkopuolella (out-of-range) oleva indeksi ei ole virhe vaan raaka muistin luku. Se on okei, kun indeksi on aina kelvollinen, mutta se lakkaa olemasta okei CFF- tai Type2-merkkijonotulkin sisällä, missä indeksi voi tulla tiedostosta. Merkkijono (charstring), joka ponnauttaa (pops) operandin pois tyhjästä pinosta (stack), tuottaa indeksin miinus yksi; glyyfitunniste, joka on yhden pielessä glyyfimäärään nähden, indeksoi yhden paikan (slot) ohi lopun. Aluetarkistuksen ollessa pois päältä, molemmista tulee aito (genuine) rajojen ulkopuolinen (out-of-bounds) pääsy eikä kiinniotettava (catchable) poikkeus (exception), ja koska paikat sisältävät viitelaskettuja (reference-counted) AnsiString-arvoja, harhautunut luku (stray read) voi myös korruptoida merkkijonon viitelaskurin (reference count)

Kovettaminen (hardening) ei kytkenyt aluetarkistusta takaisin päälle kuumalla polulla. Se teki indekseistä ensin todistettavasti päteviä (provably valid): ennen operandipinon (operand stack) yläosan ottamista tulkki tarkistaa, että pino on ei-tyhjä (non-empty), ja jokainen indeksisuoja (index guard) kirjoitettiin tiukkana pienempi kuin -ehtona laskuria (count) vastaan pikemminkin kuin pienempi-tai-yhtä-suuri-ehtona, joka sallii yhden virheen (off-by-one). Direktiivi (directive) siirtää vastuun rajoista kääntäjältä (compiler) sinulle, ja sen poistama validointi on palautettava käsin jokaisessa aloituspisteessä (entry point)

Rajoittamaton rekursio (unbounded recursion) charstring-tulkissa

Type2-merkkijono (charstring) voi kutsua aliohjelmaa (subroutine), ja aliohjelma itsessään on merkkijono, joka voi kutsua toista, joten paikalliset ja globaalit aliohjelman kutsuoperaattorit antavat tiedoston päättää kuinka syvälle se menee. Aliohjelma, joka kutsuu itseään, suoraan tai syklin kautta, rekursioituu loputtomasti kunnes alkuperäinen (native) pino on uupunut ja prosessi kuolee. Tuo on CWE-674, hallitsematon rekursio (uncontrolled recursion)

Type1-tulkki suojasi jo tältä. Siinä oli kutsujen syvyyslaskuri (call-depth counter) ja katto, PLType1MaxCallDepth, ja se kieltäytyi laskeutumasta sen ohi, mikä heijastaa syvyysrajaa, jonka itse Type1-määrittely (specification) nimeää. Type2-tulkilla, joka lisättiin myöhemmin ja on rakenteellisesti samankaltainen, ei ollut samaa suojaa, ja käsin rakennettu fontti, jossa on aliohjelma, joka kutsuu omaa numeroaan, kävelee suoraan puuttuvan tarkistuksen läpi pinon ylivuotoon (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

Korjaus oli antaa Type2-polulle sama rajoitettu syvyys (bounded depth), joka sen Type1-sisaruksella jo oli. Mikä tahansa rekursiivinen laskeutuminen hyökkääjän hallitseman rakenteen yli, olipa kyse sitten fontin aliohjelmista (font subroutines), sisäkkäisestä taulukosta (nested array) tai ristiviittausketjusta (cross-reference chain), tarvitsee syvyyskaton, jota syöte (input) ei voi nostaa

Alustamaton muisti (uninitialized memory), joka vuotaa tulosteeseen

Hienovaraisin vika vuoti keon (heap) sisällön salauksesta purettuun (decrypted) tulosteeseen, ja syynä on SetLength-ominaisuus, joka on helppo unohtaa. Kun kasvatat AnsiString-merkkijonoa komennolla SetLength, Delphi varaa tavut, mutta ei nollaa niitä, joten uusi alue pitää sisällään mitä tahansa, mikä aiemmin oli tuossa keon muistissa (heap memory). Jos jokainen tavu myöhemmin kirjoitetaan, tällä ei ole koskaan väliä; jos polku jättää osan puskurista (buffer) kirjoittamatta ja sitten palauttaa sen datana, nuo vanhentuneet (stale) tavut lähtevät liikkeelle tuloksen mukana. Tuo on CWE-457, alustamattoman muistin (uninitialized memory) käyttö, ja kun tulos ylittää luottamusrajan (trust boundary), siitä tulee tietovuoto (information leak)

AES-CBC-salauksen purkupolku (decryption path) törmäsi täsmälleen tähän. Tulostepuskuri (output buffer) mitoitettiin (sized) komennolla SetLength, ja salauksenpurkaja käsitteli salatekstiä (ciphertext) yhden 16 tavun lohkon kerrallaan. Kun salatekstin pituus ei ollut 16:n kerrannainen (multiple), pituus, jonka hyökkääjä voi valita, perässä tulevaa osittaista lohkoa (trailing partial block) ei koskaan kirjoitettu, joten nuo viimeiset tavut pitivät sisällään keon sisällön, jonka SetLength jätti jälkeensä, ja puskuri palautettiin asiakirjaobjektin (document object) salauksesta purettuna selvätekstinä (decrypted plaintext). Lääke on kaksi suojusta, eikä kumpikaan yksinään riitä: salauksen purkamisen tulopiste hylkää nyt minkä tahansa salatekstin, jonka pituus ei ole lohkon koon kerrannainen, ja takapysäyttimenä (backstop) tuloste tyhjennetään (cleared) FillChar:illa ennen käyttöä, jotta mikä tahansa polku, joka ei onnistu kirjoittamaan aluetta, palauttaa nollia keon jäänteiden (heap residue) sijaan

Mitä läpikäynti (pass) jättää sinulle

Viisi vikaa ovat eri bugeja, mutta ne riimmaavat. Kokonaisluvun leveys, joka kietoo (wraps) tulon (product), kenttätyyppi (field type), joka kiinnittää suojan (guard) jatkuvaan epätoteen (constant false), aluetarkistus (range check), joka on poistettu käytöstä, missä indeksit lakkasivat olemasta turvallisia, rekursio ilman pohjaa (floor), ja puskuri, jota kieli kieltäytyi nollaamasta. Jokaisessa näistä Delphi teki täsmälleen sen, mitä se määrittelee, koska kieli antaa sinulle aritmetiikan, joka kietoutuu (wraps), kavennuksen, joka on äänetön, aluetarkistukset (range checks), jotka voit kytkeä pois päältä, rekursion ilman sisäänrakennettua rajaa, ja muistinvarauksen, joka ei alusta (initialize). Se on sopimus, ja Pascal-jäsentäjä (parser) täyttää sen omistamalla neljä asiaa käsin jokaisella rajalla, jota tiedosto hallitsee: kokonaisluvun leveyden, aluetarkistuksen (range checking), rekursion syvyyden ja puskurin alustamisen (buffer initialization)

Nämä viat on korjattu nykyisissä PDFlibPas-julkaisuissa, Delphin ja C++Builderin moottorissa (engine). Jos työsi ulottuu myös siihen, kuinka tiedosto väittää olevansa suojattu, rinnakkaiset huomautukset salauksen ja käyttöoikeuksien auditoinnista (auditing encryption and permissions) ja PDF/A- ja PDF/UA-esitarkistuksesta (PDF/A and PDF/UA preflight) kattavat saman jäsentäjän analyysipuolen, ja kaikki tämä toimitetaan PDFlibPas Delphi PDF Library -kirjastossa latauksen, renderöinnin ja allekirjoittamisen (signing APIs) ohella, joita käsitellään muualla tässä blogissa