Technical Article

Herding av en Pascal PDF-tolker mot ondsinnede filer

En PDF er ikke et dokument du åpner. Det er et lite program du kjører. Hver innebygd font er en stakkbasert tolker som venter på tegnstrenger, hvert bilde er en dekoder matet med felt for bredde, høyde og fargedybde som filen har valgt, og hver strøm ankommer pakket inn i filtre med parametere filen har satt. Ingen av disse tallene er dine. De kom fra hvem som enn produserte filen, som i virkelige arbeidsoppgaver er en kundes faktura eller et vedlegg fra en ukjent avsender. Dekoderne som gjør disse bytene om til piksler og glyfer er angrepsflaten, og en tolker som stoler på inndataene sine der, er én feilformet fil unna et krasj eller verre.

PDFlibPas gikk gjennom en herdingsrunde som behandlet hele dekodingsbanen som fiendtlig, på tvers av fontprogrammene (TrueType, Type1, CFF og CMap-tabellene), bildedekoderne (PNG, GIF, TIFF, JBIG2 og CCITT gruppe 3 og gruppe 4) og strømfiltrene (LZW, ASCII85 og Flate-prediktorer). Det som følger er fem feilklasser den lukket, hver forankret i den spesifikke Delphi-oppførselen som gjorde det mulig. De er rettet i nåværende utgivelser, og de samme mønstrene går igjen i enhver Pascal-kode som tolker upålitelige inndata。

En heltallsoverflyt som gir deg en for liten buffer

Den klassiske minnesikkerhetsfeilen i en bildedekoder er et dimensjonsprodukt som ruller rundt. En dekoder leser bredde, høyde, komponentantall og bitdybde, multipliserer dem for å dimensjonere utdataene sine, tildeler det antallet byte, og skriver deretter bildet i dets sanne dimensjoner. Hvis multiplikasjonen gjøres med 32-bit aritmetikk, det produktet kan rulle rundt til en liten verdi selv når hver enkelt faktor er innenfor et fornuftig område, slik at tildelingen lykkes, blir altfor liten, og dekodingen skriver forbi slutten av den. Dette er CWE-190, heltallsoverflyt, som fører til en heap-out-of-bounds-skriving (CWE-787) ett trinn senere.

Den delte bildebanen begrenset allerede hver dimensjon til 65535; de frittstående dekoderne arvet ikke alle denne begrensningen. Et uttrykk som radbyte-ganger-høyde som ByteCount * FHeight, eller et uttrykk per piksel som FWidth * Components * BitDepth, er et 32-bit produkt i Delphi når begge operander er 32-bit heltall, uavhengig av hvor bred variabelen du tildeler resultatet til er. En bredde og en høyde på 60000 are each plausible for a large scan, but their product in bytes overruns a signed 32-bit range and the length comes out small. Den samme fellen fantes i ZLib-prediktorsteget, BitsPerComponent * Colors * Columns.

Løsningen er å gjøre minst én operand til Int64 slik at hele uttrykket evalueres i 64-bit, og deretter sammenligne mot MaxInt og avvise filen før det snevres inn igjen for å kalle 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);

Det som gjør dette til et Delphi-problem snarere enn et generelt, er den lydløse innsnevringen. Å tildele et for bredt uttrykk til et 32-bit mål er en lovlig konvertering kompilatoren ikke vil advare om som standard, og områdekontroll fanger ikke opp en avrunding som skjer før verdien i det hele tatt brukes som en indeks. Lar du produktet forbli på 32 bits, gir språket deg i all hemmelighet en lengde som lyver om hvor mye minne dekodingen er i ferd med å berøre.

En felt-type som gjør en sikkerhetskontroll umulig å utløse

En TIFF-fil er en kjede av bildefilkataloger, der hver bærer byte-forskyvningen til den neste. En ondsinnet fil kan peke denne kjeden tilbake til seg selv, og en leser som går gjennom den uten en stoppebedingelse vil kjøre evig. Det er CWE-835, en uendelig løkke drevet av angriperkontrollert inndata, og forsvaret er en teller som stopper når den passerer en grense ingen legitim fil ville nå.

Sidetelleren ble deklarert som Word, som i Delphi holder 0 til 65535. Løkken hadde en avslutningskontroll av typen "stopp når sidetallet overskrider 65535," som ser riktig ut helt til du merker at operanden og terskelen deler en øvre grense. En Word kan aldri være større enn 65535, så sammenligningen er strukturelt alltid usann: Når telleren når 65535, ruller neste inkrement den tilbake til 0, kontrollen ser aldri en verdi over taket, og en løpende IFD-kjede holder leseren spinnende.

Løsningen var å utvide feltet slik at kontrollen kan uttrykke en verdi telleren faktisk kan holde. Med TPDFTIFF.FPageCount deklarert som Integer, den samme FPageCount > 65535-sammenligningen blir nåbar, løkken avsluttes, og den offentlige egenskapen PageCount endret type for å samsvare uten å ødelegge for noen kaller. Hver gang en grensesjekk har formen Value > MaxValueOfType(Value) og operanden allerede har en type med akkurat denne maksimale verdien, betingelsen er en konstant usannhet: utvid typen, eller test likhet mot maksimumsverdien slik at den kan utløses.

Områdekontroll deaktivert på en kritisk bane

Med områdekontroll aktivert setter Delphi inn en grensesjekk på hver array- og strengindeks, noe som er forskjellen mellom at en indeks utenfor området utløser en catchable ERangeError, og at den samme indeksen leser eller skriver til minne som ikke tilhører strukturen. Kritiske baner deaktiverer den noen ganger med et lokalt {$R-}-direktiv, noe som kan forsvares helt til indeksene slutter å være pålitelige.

Liste-aksessoren font-tolkerne støtter seg på, TPDFlibStringList.Get, er akkurat en slik bane. På Windows kompileres den med områdekontroll av og indekserer sitt interne lager direkte, slik at en indeks utenfor området ikke er en feil, men en rå minnetilgang. Det er greit når indeksen alltid er gyldig, og det slutter å være greit inne i en CFF- eller Type2-charstring-tolker, der indeksen kan komme fra filen. En charstring som popper en operand av en tom stakk produserer en indeks på minus én; en glyf-identifikator som er feil med én i forhold til glyf-antallet indekserer ett spor forbi slutten. Med områdekontroll av blir begge deler til en reell out-of-bounds-tilgang i stedet for et catchable unntak, og fordi sporene inneholder referansetalte AnsiString-verdier, en tilfeldig lesing kan også korrumpere en strengs referansetelling.

Herdingen slo ikke områdekontrollen på igjen for den kritiske banen. Den gjorde indeksene beviselig gyldige først: Før den tar toppen av operandstakken, sjekker tolkeren at stakken ikke er tom, og hver indekskontroll ble skrevet som en streng "mindre enn" mot antallet, i stedet for "mindre enn eller lik" som tillater feilen med én. Direktivet flytter ansvaret for grenser fra kompilatoren til deg, og valideringen det fjernet må settes tilbake for hånd på hvert inngangspunkt.

Ubegrenset rekursjon i en charstring-tolker

En Type2-charstring kan kalle en subrutine, og en subrutine er i seg selv en charstring som kan kalle en annen, slik at de lokale og globale subrutinekall-operatorene lar filen bestemme hvor dypt den skal gå. En subrutine som kaller seg selv, direkte eller via en syklus, rekurserer uten ende til den opprinnelige stakken er brukt opp og prosessen dør. Det er CWE-674, ukontrollert rekursjon。

Type1-tolkeren beskyttet allerede mot dette. Den hadde en teller for kalldybde og et tak, PLType1MaxCallDepth, og nektet å gå dypere enn dette, noe som gjenspeiler dybdegrensen Type1-spesifikasjonen selv navngir. Type2-tolkeren, som ble lagt til senere og er strukturelt lik, hadde ikke den samme kontrollen, og en manuelt oppbygd font med en subrutine som kaller sitt eget nummer, går rett gjennom den manglende sjekken og inn i en stakkoverflyt.

// 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

Løsningen var å gi Type2-banen den samme begrensede dybden som sin Type1-søsken allerede hadde. Enhver rekursiv nedstigning over en angriperkontrollert struktur, enten det er fontsubrutiner, en nestet array eller en kryssreferansekjede, trenger et dybdetak inndataene ikke kan løfte.

Uinitialisert minne som lekker inn i utdataene

Den mest subtile feilen lekket heap-innhold inn i dekrypterte utdata, og årsaken er en egenskap ved SetLength som er lett å glemme. Når du utvider en AnsiString med SetLength, Delphi tildeler bytene, men nullstiller dem ikke, slik at det nye området beholder det som tidligere lå i dette heap-minnet. Hvis hver byte skrives etterpå, betyr ikke dette noe; men hvis en bane lar deler av bufferen forbli uskrevet og deretter returnerer den som data, blir disse foreldede bytene med i resultatet. Det er CWE-457, bruk av uinitialisert minne, og når resultatet krysser en tillitsgrense blir det en informasjonslekkasje.

Dekrypteringsbanen for AES-CBC ble rammet av akkurat dette. Utdatabufferen ble dimensjonert med SetLength, og dekrypteringen prosesserte kryptoteksten én 16-byte blokk om gangen. Når kryptotekstens lengde ikke var et multiplum av 16 (en lengde en angriper kan velge), ble den avsluttende delblokken aldri skrevet, slik at disse siste bytene beholdt heap-innholdet SetLength etterlot seg, og bufferen ble gitt tilbake som den dekrypterte klarteksten til et dokumentobjekt. Løsningen er to kontroller, og ingen av dem er tilstrekkelig alene: Dekrypteringens inngangspunkt avviser nå enhver kryptotekst hvis lengde ikke er et multiplum av blokkstørrelsen, og som en ekstra sikkerhet tømmes utdataene med FillChar før bruk, slik at enhver bane som ikke klarer å skrive til et område returnerer nuller i stedet for heap-rester.

Hva prosessen etterlater deg med

De fem feilene er forskjellige, men de ligner hverandre. En heltallsbredde som ruller rundt et produkt, en felt-type som låser en sjekk til en konstant usannhet, en områdekontroll deaktivert der indeksene sluttet å være trygge, en rekursjon uten bunn, og en buffer språket nektet å nullstille. I hvert enkelt tilfelle gjorde Delphi akkurat det den er definert til å gjøre, fordi språket gir deg aritmetikk som ruller rundt, innsnevring som er lydløs, områdekontroller du kan slå av, rekursjon uten innebygd grense, og tildeling som ikke initialiserer. Det er kontrakten, og en Pascal-tolker oppfyller den ved å kontrollere fire ting manuelt ved hver grense filen styrer: heltallsbredde, områdekontroll, rekursjonsdybde og bufferinitialisering.

Disse feilene er lukket i gjeldende PDFlibPas-utgivelser, motoren for Delphi og C++Builder. Hvis arbeidet ditt også berører hvordan en fil hevder å være beskyttet, de tilhørende notatene om å revidere kryptering og tillatelser og om PDF/A- og 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.