En PDF är inte ett dokument du öppnar. Det är ett litet program du kör. Varje inbäddat teckensnitt är en stackbaserad tolkningsmodul som väntar på teckensträngar (charstrings), varje bild är en avkodare som matas med fält för bredd, höjd och bitdjup som filen har valt, och varje ström anländer insvept i filter vars parametrar filen har ställt in. Inget av dessa tal är dina. De kom från den som skapade filen, vilket på en verklig arbetsbelastning är en kundfaktura eller en bilaga från en okänd avsändare. Avkodarna som förvandlar dessa bytes till pixlar och glyfer är den attackerbara ytan, och en tolk som litar på sina indata där är en enda felaktig fil från en krasch eller något värre
PDFlibPas gick igenom en härdningsrunda som behandlade hela avkodningssökvägen som fientlig, över teckensnittsprogrammen (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-prediktorer). Vad som följer är fem defektklasser som den stängde, var och en grundad i det specifika Delphi-beteende som gjorde dem möjliga. De är åtgärdade i nuvarande versioner, och samma mönster återkommer i all Pascal-kod som tolkar opålitliga indata
Praktiskt sammanhang
Det klassiska minnessäkerhetsfelet i en bildavkodare är en dimensionsprodukt som slår runt. En avkodare läser bredd, höjd, komponentantal och bitdjup, multiplicerar dem för att dimensionera sina utdata, allokerar så många bytes och skriver sedan bilden med sina verkliga dimensioner. Om multiplikationen görs i 32-bitars aritmetik kan produkten slå runt till ett litet värde även när varje enskild faktor ligger inom ett rimligt intervall, så att allokeringen lyckas, blir alldeles för liten, och avkodningen kliver utanför dess slut. Detta är CWE-190, heltalsspill (integer overflow), vilket leder till en heap-skrivning utanför gränserna (CWE-787) ett steg senare
Den gemensamma bildsökvägen begränsade redan varje dimension till 65535; de fristående avkodarna ärvde inte alla den begränsningen. Ett uttryck för rad-bytes-gånger-höjd som ByteCount * FHeight, eller ett uttryck per pixel som FWidth * Components * BitDepth, är en 32-bitars produkt i Delphi när båda operanderna är 32-bitars heltal, oavsett hur bred variabeln du tilldelar resultatet till är. En bredd och en höjd på 60000 är båda rimliga för en stor skanning, men deras produkt i bytes överskrider ett signerat 32-bitarsintervall och längden blir liten. Samma fälla fanns i ZLib-prediktorsteget, BitsPerComponent * Colors * Columns
Lösningen är att göra minst en operand till Int64 så att hela uttrycket utvärderas i 64-bitars, sedan jämföra mot MaxInt och avvisa filen innan man smalnar av 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);
Det som gör detta till ett Delphi-problem snarare än ett generiskt är den tysta avsmalningen. Att tilldela ett för brett uttryck till en 32-bitars destination är en laglig konvertering som kompilatorn inte varnar för som standard, och intervallkontroll (range checking) fångar inte upp ett spill som sker innan värdet någonsin används som ett index. Lämna produkten på 32 bitar och språket ger dig i tysthet en längd som ljuger om hur mycket minne avkodningen är på väg att röra vid
Implementeringssteg
En TIFF-fil är en kedja av bildfilskataloger (image file directories), där varje katalog bär byte-offseten för nästa. En skadlig fil kan peka den kedjan tillbaka till sig själv, och en läsare som går igenom den utan ett stoppvillkor körs för evigt. Detta är CWE-835, en oändlig loop som drivs av angriparkontrollerade indata, och försvaret är en räknare som stoppar när den passerar en gräns som ingen legitim fil skulle nå
Sidräknaren deklarerades som Word, vilket i Delphi rymmer 0 till 65535. Loopen bar på ett avslutningsskydd av formen "stoppa när sidantalet överstiger 65535", vilket verkar korrekt tills du märker att operanden och tröskelvärdet delar en övre gräns. En Word kan aldrig bli större än 65535, så jämförelsen är strukturellt alltid falsk: när räknaren når 65535 slår nästa ökning tillbaka den till 0, skyddet ser aldrig ett värde över taket, och en loopande IFD-kedja håller läsaren spinnande
Lösningen var att bredda fältet så att skyddet kan uttrycka ett värde som räknaren faktiskt kan hålla. Med TPDFTIFF.FPageCount deklarerad som Integer blir samma jämförelse FPageCount > 65535 nåbar, loopen avslutas och den offentliga egenskapen PageCount ändrade typ för att matcha utan att störa någon anropare. När en gränskontroll har formen Value > MaxValueOfType(Value) och operanden redan är typad till exakt det maximumet, är villkoret konstant falskt: bredda typen, eller testa likhet mot maximumet så att det kan triggas
Kontrollpunkter
Med intervallkontroll (range checking) på sätter Delphi in en gränskontroll på varje array- och strängindex, vilket är skillnaden mellan att ett index utanför intervallet utlöser en fångbar ERangeError och att samma index läser eller skriver minne som inte tillhör strukturen. Kritiska sökvägar (hot paths) inaktiverar ibland detta med ett lokalt {$R-}-direktiv, vilket är försvarbart ända tills indexen slutar vara pålitliga
Liståtkomsten som teckensnittstolkarna lutar sig mot, TPDFlibStringList.Get, är just en sådan sökväg. På Windows komprimeras den med intervallkontroll avstängd och indexerar sitt underliggande minne direkt, så ett index utanför intervallet är inte ett fel utan en rå minnesåtkomst. Det är bra när indexet alltid är giltigt, och det slutar vara bra inuti en CFF- eller Type2-teckensträngstolk (charstring interpreter), där indexet kan komma från filen. En teckensträng som plockar en operand från en tom stack producerar ett index på minus ett; en glyfidentifierare fel med ett mot glyfantalet indexerar en plats förbi slutet. Med intervallkontroll avstängd blir båda en verklig åtkomst utanför gränserna istället för ett fångbart undantag, och eftersom platserna innehåller referensräknade AnsiString-värden kan en felaktig läsning också korrumpera en strängs referensräkning
Härdningen slog inte på intervallkontrollen igen för den kritiska sökvägen. Den gjorde indexen bevisbart giltiga först: innan operanden tas från stacken kontrollerar tolken att stacken inte är tom, och varje indexskydd skrevs som ett strikt mindre-än mot antalet snarare än mindre-än-eller-lika-med som tillåter fel-med-ett. Direktivet flyttar ansvaret för gränser från kompilatorn till dig, och den validering det tog bort måste läggas tillbaka för hand vid varje startpunkt
Praktiskt sammanhang
En Type2-teckensträng kan anropa en underrutin, och en underrutin är i sig en teckensträng som kan anropa en annan, så de lokala och globala anropsoperatorerna för underrutiner låter filen bestämma hur djupt det går. En underrutin som anropar sig själv, direkt eller genom en cykel, rekursivt utan slut tills den interna stacken är uttömd och processen dör. Detta är CWE-674, okontrollerad rekursion
Type1-tolken skyddade redan mot detta. Den bar på en anropsdjupsräknare och ett tak, PLType1MaxCallDepth, och vägrade att gå djupare än så, vilket återspeglar den djupgräns som Type1-specifikationen själv anger. Type2-tolken, som lades till senare och strukturellt liknar den, bar inte på samma skydd, och ett handbyggt teckensnitt med en underrutin som anropar sitt eget nummer går rakt igenom den saknade kontrollen in i ett stackspill
// 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 att ge Type2-sökvägen samma begränsade djup som dess Type1-syskon redan hade. Alla rekursiva genomgångar över en angriparkontrollerad struktur, oavsett om det är teckensnittsunderrutiner, en nästlad array eller en korsreferenskedja, behöver ett djuptak som indata inte kan lyfta
Implementeringssteg
Det mest subtila felet läckte heap-innehåll till dekrypterade utdata, och orsaken är en egenskap hos SetLength som är lätt att glömma. När du utökar en AnsiString med SetLength, allokerar Delphi bytes men nollställer dem inte, så den nya regionen innehåller vad som än fanns i det heap-minnet tidigare. Om varje byte därefter skrivs spelar detta ingen roll; om en sökväg lämnar en del av bufferten oskriven och sedan returnerar den som data, följer de gamla bytes med resultatet ut. Detta är CWE-457, användning av oinitierat minne, och när resultatet korsar en förtroendegräns blir det en informationsläcka
AES-CBC-dekrypteringssökvägen drabbades av exakt detta. Utdatabufferten dimensionerades med SetLength och dekrypteraren bearbetade chiffertexten ett 16-bytes block i taget. När chiffertextens längd inte var en multipel av 16, en längd en angripare kan välja, skrevs det avslutande delblocket aldrig, så de sista bytes behöll heap-innehållet som SetLength lämnat efter sig och bufferten lämnades tillbaka som den dekrypterade klartexten av ett dokumentobjekt. Lösningen är två skydd, och inget av dem enbart är tillräckligt: startpunkten för dekryptering avvisar nu all chiffertext vars längd inte är en multipel av blockstorleken, och som ett komplement rensas utdata med FillChar före användning så att alla sökvägar som misslyckas med att skriva en region returnerar nollor istället för heap-rester
Kontrollpunkter
De fem defekterna är olika buggar, men de rimmar. En heltalsbredd som slår runt en produkt, en fälttyp som låser ett skydd till konstant falskt, en avstängd intervallkontroll där indexen slutade vara säkra, en rekursion utan botten och en buffert som språket vägrade att nollställa. I var och en av dem gjorde Delphi exakt vad det definierar, eftersom språket ger dig aritmetik som slår runt, avsmalning som är tyst, intervallkontroller du kan stänga av, rekursion utan inbyggd gräns och allokering som inte initierar. Det är kontraktet, och en Pascal-tolk uppfyller det genom att hantera fyra saker för hand vid varje gräns som filen styr: heltalsbredd, intervallkontroll, rekursionsdjup och buffertinitiering
Dessa defekter är stängda i aktuella PDFlibPas-versioner, motorn för Delphi och C++Builder. Om ditt arbete också sträcker sig till hur en fil påstår sig vara skyddad, beskriver de medföljande anteckningarna om granskning av kryptering och behörigheter samt om PDF/A- och PDF/UA-förhandskontroll analyssidan av samma tolk, och allt detta levereras inuti PDFlibPas Delphi PDF Library tillsammans med de API:er för inläsning, rendering och signering som beskrivs på andra ställen i denna blogg