Teknisk artikel

Att härda en Pascal PDF-tolk mot skadliga filer

En PDF är inte ett dokument du öppnar. Det är ett litet program du kör. Varje inbäddat typsnitt är en stackbaserad interpretator (interpreter) som väntar på charstrings, varje bild är en avkodare matad med bredd-, höjd- och bitdjupsfält som filen valde, och varje ström anländer inlindad (wrapped) i filter vars parametrar filen satte. Inga av de siffrorna är dina. De kom från vem som än producerade filen, vilket på en riktig arbetsbelastning är en kunds faktura eller en bilaga från en okänd avsändare. Avkodarna som omvandlar de byten till pixlar och glyfer (glyphs) är attackytan, och en tolk som litar på sin inmatning där är en missformad fil bort från en krasch eller värre

PDFlibPas gick igenom en härdningsrunda (hardening pass) som behandlade hela avkodningsvägen som fientlig, över typsnittsprogrammen (TrueType, Type1, CFF och CMap-tabellerna), bildavkodarna (PNG, GIF, TIFF, JBIG2 och CCITT Group 3 och Group 4), och strömfiltren (LZW, ASCII85 och Flate-prediktorerna). Vad som följer är fem defektklasser som den stängde, var och en grundad i det specifika Delphi-beteende som gjorde det möjligt. De är fixade i aktuella releaser, och samma former återkommer i all Pascal-kod som tolkar opålitlig inmatning

En heltalsöverspillning (integer overflow) som ger dig en underdimensionerad buffert

Den klassiska minnessäkerhetsbuggen i en bildavkodare är en dimensionsprodukt som slår runt (wraps). En avkodare läser bredd, höjd, komponentantal och bitdjup, multiplicerar dem för att storleksanpassa sin utmatning, allokerar så många byte, sedan skriver den bilden i dess sanna dimensioner. Om multiplikationen görs i 32-bitars aritmetik kan produkten slå runt till ett litet värde även när varje enskild faktor är inom ett rimligt intervall, så allokeringen lyckas, blir alldeles för liten, och avkodningen går av kanten (walks off the end) på den. Detta är CWE-190, integer overflow, som leder till en heap out-of-bounds write (CWE-787) ett steg senare

Den delade bildvägen (shared image path) begränsade (clamped) redan varje dimension till 65535; de fristående avkodarna ärvde inte alla den begränsningen (clamp). Ett rad-byte-gånger-höjd-uttryck (row-bytes-times-height) som ByteCount * FHeight, eller ett per-pixel-uttryck som FWidth * Components * BitDepth, är en 32-bitars produkt i Delphi när båda operanderna är 32-bitars heltal, oberoende av hur bred variabeln du tilldelar resultatet till är. En bredd och en höjd på 60000 är var och en plausibla för en stor skanning, men deras produkt i byte springer över (overruns) en tecknad 32-bitars räckvidd och längden blir liten. Samma fälla levde i ZLib-prediktorns steglängd (stride), BitsPerComponent * Colors * Columns

Lösningen är att göra åtminstone en operand till Int64 så att hela uttrycket utvärderas i 64-bitar, sedan jämföra mot MaxInt och avvisa filen innan man krymper ner (narrowing back down) igen för att anropa 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);

Vad som gör detta till ett Delphi-problem snarare än ett generiskt sådant är den tysta minskningen (silent narrowing). Att tilldela ett för brett uttryck in i en 32-bitars destination är en laglig konvertering som kompilatorn som standard inte kommer att varna för, och räckviddskontroll (range checking) fångar inte en omslagning (wrap) som sker innan värdet någonsin används som ett index. Lämna produkten vid 32 bitar och språket ger dig tyst en längd som ljuger om hur mycket minne avkodningen är på väg att vidröra

En fälttyp som gör en vakt (guard) omöjlig att utlösa (fire)

En TIFF-fil är en kedja av bildfilkataloger (image file directories), var och en bärande bytoffseten (byte offset) till nästa. En skadlig fil kan peka den kedjan tillbaka på sig själv, och en läsare som går igenom den utan ett stoppvillkor kör i all oändlighet. Det är CWE-835, en oändlig loop driven av angriparkontrollerad inmatning, och försvaret är en räknare som stannar när den passerar en gräns som ingen legitim fil skulle nå

Sidräknaren var deklarerad som Word, vilket i Delphi rymmer 0 till 65535. Loopen bar en termineringsvakt i formen "stoppa när sidantalet överstiger 65535," vilket läses som korrekt tills du märker att operanden och tröskelvärdet delar en övre gräns. En Word kan aldrig vara större än 65535, så jämförelsen är strukturellt sett alltid falsk: när räknaren når 65535 slår nästa inkrementering runt den tillbaka till 0, vakten ser aldrig ett värde ovanför taket, och en loopande IFD-kedja håller läsaren snurrande (spinning)

Lösningen var att bredda fältet så att vakten kan uttrycka ett värde som räknaren faktiskt kan hålla. Med TPDFTIFF.FPageCount deklarerat som Integer blir samma FPageCount > 65535-jämförelse nåbar (reachable), loopen terminerar, och den publika PageCount-egenskapen (property) bytte typ för att matcha utan att bryta (breaking) någon anropare. Närhelst en gränskontroll (bound check) har formen Value > MaxValueOfType(Value) och operanden redan är typad till exakt det maxvärdet, är villkoret ett konstant falskt: bredda typen, eller testa jämlikhet mot maxvärdet så att det kan utlösa (trigger)

Räckviddskontroll (range checking) avstängd på en het väg (hot path)

Med räckviddskontroll påslagen sätter Delphi in en gränskontroll på varje array och strängindex, vilket är skillnaden mellan att ett index utanför räckvidden (out-of-range index) lyfter en fångbar ERangeError och att samma index läser eller skriver minne som inte tillhör strukturen. Heta vägar avaktiverar det ibland med ett lokalt {$R-}-direktiv, vilket är försvarbart (defensible) ända tills indexen slutar vara pålitliga

List-accessorn (list accessor) som typsnittstolkarna förlitar sig på, TPDFlibStringList.Get, är exakt en sådan väg. I Windows är den kompilerad med räckviddskontroll avstängd och indexerar sitt backing store direkt, så ett index utanför räckvidden är inte ett fel utan en rå minnesåtkomst (raw memory access). Det är okej när indexet alltid är giltigt, och det slutar vara okej inuti en CFF- eller Type2-charstring-tolk (charstring interpreter), där indexet kan komma från filen. En charstring som plockar (pops) en operand från en tom stack producerar ett index på minus ett; en glyfidentifierare fel med ett (off by one) mot glyfantalet indexerar ett fack (slot) förbi slutet. Med räckviddskontroll avstängd blir båda en genuin out-of-bounds-åtkomst istället för ett fångbart undantag, och eftersom facken håller referensräknade (reference-counted) AnsiString-värden kan en vilsekommen läsning också korrumpera en strängs referensräkning

Härdningen slog inte på räckviddskontrollen igen för den heta vägen. Den gjorde indexen bevisligen (provably) giltiga först: innan toppen av operandstacken tas kontrollerar tolken att stacken är icke-tom, och varje indexvakt skrevs som en strikt mindre-än mot antalet hellre än en mindre-än-eller-lika-med som tillåter ett off-by-one. Direktivet flyttar ansvaret för gränser från kompilatorn till dig, och den validering den tog bort måste läggas tillbaka för hand vid varje ingångspunkt (entry point)

Obegränsad rekursion i en charstring-tolk

En Type2 charstring kan anropa en subrutin, och en subrutin är i sig en charstring som kan anropa en annan, så de lokala och globala subrutinanrops-operatörerna låter filen bestämma hur djupt den går. En subrutin som anropar sig själv, direkt eller genom en cykel, rekurserar utan slut tills den inbyggda stacken är utmattad (exhausted) och processen dör. Det är CWE-674, uncontrolled recursion

Type1-tolken skyddade redan mot detta. Den bar på en anropsdjupsräknare (call-depth counter) och ett tak, PLType1MaxCallDepth, och vägrade sjunka (descend) förbi det, vilket återspeglar den djupsgräns Type1-specifikationen själv namnger. Type2-tolken, tillagd senare och strukturellt liknande, bar inte samma vakt, och ett handbyggt typsnitt med en subrutin som anropar sitt eget nummer går rakt igenom den saknade kontrollen (missing check) in i en 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

Fixen var att ge Type2-vägen samma begränsade djup (bounded depth) som sitt Type1-syskon redan hade. All rekursiv nedstigning (recursive descent) över en attackerarkontrollerad (attacker-controlled) struktur, oavsett om det är typsnitts-subrutiner, en nästlad array, eller en korsreferenskedja, behöver ett djuptak (depth ceiling) som inmatningen inte kan lyfta

Oinitialiserat minne som läcker in i utmatningen

Den mest subtila defekten läckte heap-innehåll in i dekrypterad utmatning, och orsaken är en egenskap hos SetLength som är lätt att glömma. När du får en AnsiString att växa med SetLength allokerar Delphi byten men nollställer dem inte, så den nya regionen rymmer (holds) vad som än låg i det heap-minnet tidigare. Om varje byte därefter skrivs spelar detta aldrig någon roll; om en väg lämnar en del av bufferten oskriven och därefter returnerar den som data, rider de gamla (stale) byten ut med resultatet. Det är CWE-457, use of uninitialized memory, och när resultatet korsar en tillitsgräns (trust boundary) blir det en informationsläcka (information leak)

AES-CBC-dekrypteringsvägen (decryption path) träffade exakt detta. Utmatningsbufferten dimensionerades med SetLength och dekrypteraren bearbetade chiffertexten ett 16-byteblock i taget. När chiffertextens längd inte var en multipel av 16, en längd en angripare kan välja, skrevs aldrig det avslutande (trailing) partiella blocket, så dessa sista byte behöll heap-innehållet SetLength lämnat bakom sig och bufferten räcktes tillbaka som den dekrypterade klartexten av ett dokumentobjekt. Botemedlet är två vakter, och ingendera (neither) är tillräcklig på egen hand: dekrypteringens ingångspunkt avvisar (rejects) nu all chiffertext vars längd inte är en multipel av blockstorleken, och som ett skyddsnät (backstop) rensas (cleared) utmatningen med FillChar före användning så att någon väg som misslyckas med att skriva en region returnerar nollor hellre än heap-rester (heap residue)

Vad rundan (the pass) lämnar dig med

De fem defekterna är olika buggar, men de rimmar (rhyme). En heltalsbredd som slår runt (wraps) en produkt, en fälttyp som naglar fast en vakt vid konstant falskt, en räckviddskontroll inaktiverad där indexen slutade vara säkra, en rekursion utan botten (floor), och en buffert som språket nekade att nolla. I var och en gjorde Delphi exakt vad det definierar, eftersom språket ger dig aritmetik som slår runt, minskning (narrowing) som är tyst, räckviddskontroller du kan stänga av, rekursion utan inbyggd gräns, och allokering som inte initialiserar. Det är kontraktet, och en Pascal-tolk möter det genom att handha fyra saker för hand vid varje gräns filen styr: heltalsbredd (integer width), räckviddskontroll, rekursionsdjup och buffertinitialisering

Dessa defekter är stängda i aktuella PDFlibPas-releaser, motorn för Delphi och C++Builder. Om ditt arbete också sträcker sig in i hur en fil hävdar sig vara skyddad, täcker följe-anteckningarna (companion notes) om att granska kryptering och behörigheter och om PDF/A och PDF/UA preflight analyssidan av samma tolk, och allt detta skeppas inuti PDFlibPas Delphi PDF Library tillsammans med inläsnings-, renderarings- och signerings-API:erna som täcks på annat håll på denna blogg