Technical Article

Een Pascal PDF-parser beveiligen tegen malafide bestanden

Een PDF is niet zomaar een document dat u opent. Het is een klein programma dat u uitvoert. Elk ingesloten lettertype is een op een stack gebaseerde interpreter die wacht op charstrings, elke afbeelding is een decoder die wordt gevoed met breedte-, hoogte- en kleurdieptevelden die het bestand heeft bepaald, en elke stream komt binnen verpakt in filters waarvan het bestand de parameters heeft ingesteld. Geen van die getallen is van u. Ze zijn afkomstig van degene die het bestand heeft gegenereerd, wat in de praktijk een factuur van een klant kan zijn of een bijlage van een onbekende afzender. De decoders die die bytes omzetten in pixels en glyphs vormen het aanvalsoppervlak, en een parser die zijn invoer blindelings vertrouwt, is één misvormd bestand verwijderd van een crash of erger.

PDFlibPas heeft een beveiligingsanalyse ondergaan waarbij het volledige decoderingspad als vijandig werd beschouwd: van de lettertypeprogramma's (TrueType, Type1, CFF en de CMap-tabellen) en de afbeeldingsdecoders (PNG, GIF, TIFF, JBIG2, en CCITT Groep 3 en Groep 4) tot de stream-filters (LZW, ASCII85, en de Flate-predictors). Wat volgt zijn vijf categorieën fouten die zijn verholpen, elk geworteld in het specifieke Delphi-gedrag dat ze mogelijk maakte. Ze zijn opgelost in de huidige releases, en de same shapes recur in any Pascal code that parses untrusted input.

Een integer-overflow die resulteert in een te kleine buffer

De klassieke geheugenveiligheidsfout in een afbeeldingsdecoder is een product van de dimensies dat wrapt. Een decoder leest breedte, hoogte, aantal componenten en kleurdiepte, vermenigvuldigt deze om de uitvoer te dimensioneren, alloceert dat aantal bytes en schrijft vervolgens de afbeelding met zijn werkelijke afmetingen. Als de vermenigvuldiging wordt uitgevoerd met 32-bits berekeningen, kan het product wrappen naar een kleine waarde, zelfs als elke afzonderlijke factor binnen een acceptabel bereik ligt. De allocatie slaagt dan wel, maar is veel te klein, waarna de decodering buiten de grenzen schrijft. Dit is CWE-190 (integer-overflow), die één stap later leidt tot een heap-out-of-bounds-schrijfactie (CWE-787).

Het gedeelde afbeeldingspad begrensde elke dimensie al op 65535; de stand-alone decoders namen die begrenzing niet allemaal over. Een uitdrukking als ByteCount * FHeight (rijbytes maal hoogte) of een expressie per pixel zoals FWidth * Components * BitDepth, is in Delphi een 32-bits product wanneer beide operanden 32-bits integers zijn, ongeacht hoe breed de variabele is waaraan u het resultaat toewijst. Een breedte en hoogte van 60000 are elk plausible voor een grote scan, maar hun product in bytes overschrijdt het signed 32-bits bereik waardoor de lengte klein uitvalt. Dezelfde valkuil bevond zich in de ZLib-predictor-stride: BitsPerComponent * Colors * Columns.

De oplossing is om ten minste één operand Int64 te maken, zodat de gehele expressie in 64-bits wordt geëvalueerd. Vervolgens wordt deze vergeleken met MaxInt om het bestand te weigeren voordat er wordt versmald om SetLength aan te roepen.

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

Wat maakt dit tot een specifiek Delphi-probleem en niet tot een generiek probleem, is de stille versmalling. Het toewijzen van een te brede expressie aan een 32-bits bestemming is een legale conversie waarvoor de compiler standaard niet waarschuwt, en range-checking vangt geen wrap op die plaatsvindt voordat de waarde als index wordt gebruikt. Als u het product op 32 bits laat staan, geeft de taal u geruisloos een lengte die niet overeenkomt met de hoeveelheid geheugen die de decodering daadwerkelijk gaat gebruiken.

Een veldtype dat de activering van een controle onmogelijk maakt

Een TIFF-bestand is een keten van afbeeldingsdirectories (image file directories), die elk de byte-offset van de volgende bevatten. Een kwaadaardig bestand kan die keten naar zichzelf laten terugwijzen, waardoor een lezer die de keten doorloopt zonder stopconditie oneindig blijft draaien. Dat is CWE-835, een oneindige lus aangedreven door invoer onder controle van een aanvaller. De verdediging is een teller die stopt zodra hij een limiet passeert die geen enkel legitiem bestand zou bereiken.

De paginateller was gedeclareerd als Word, wat in Delphi de waarden 0 tot 65535 kan bevatten. De lus bevatte een beëindigingscontrole in de vorm van "stop wanneer het aantal pagina's groter is dan 65535." Dit lijkt correct totdat u merkt dat de operand en de drempelwaarde dezelfde bovengrens delen. Een Word kan nooit groter zijn dan 65535, waardoor de vergelijking structureel altijd onwaar is: wanneer de teller 65535 bereikt, wikkelt de volgende verhoging deze terug naar 0. De controle ziet dus nooit een waarde boven het plafond, en een oneindige IFD-keten houdt de lezer aan het draaien.

De oplossing was om het veld te verbreden, zodat de controle een waarde kan uitdrukken die de teller daadwerkelijk kan bereiken. Met TPDFTIFF.FPageCount gedeclareerd als Integer wordt dezelfde vergelijking FPageCount > 65535 bereikbaar, stopt de lus en verandert het type van de publieke eigenschap PageCount dienovereenkomstig, zonder bestaande aanroepen te breken. Wanneer een grenswaardecontrole de vorm Value > MaxValueOfType(Value) heeft en de operand al van het type van dat maximum is, is de conditie altijd onwaar: verbreed het type, of controleer op gelijkheid met het maximum zodat de actie kan worden getriggerd.

Range-checking uitgeschakeld op een kritiek pad

Wanneer range-checking is ingeschakeld, voegt Delphi een grenzencontrole toe aan elke array- en stringindex. Dit is het verschil tussen een index buiten bereik die een opvangbare ERangeError veroorzaakt, en diezelfde index die geheugen leest of schrijft dat niet tot de structuur behoort. Kritieke prestatiepaden schakelen dit soms uit met een lokale {$R-}-richtlijn, wat verdedigbaar is totdat de indexen niet meer betrouwbaar zijn.

De lijst-accessor waarop de lettertype-interpreters leunen, TPDFlibStringList.Get, is exact zo'n pad. Op Windows is dit gecompileerd met range-checking uitgeschakeld en indexeert het zijn onderliggende opslag rechtstreeks, waardoor een index buiten bereik geen fout oplevert maar een directe geheugentoegang veroorzaakt. Dat is prima als de index en altijd geldig is, en het houdt op veilig te zijn binnen een CFF- of Type2-charstring-interpreter, waar de index uit het bestand kan komen. Een charstring die een operand van een lege stack haalt, produceert een index van min één; een glyph-identificatie die één afwijkt van het aantal glyphs indexeert één positie voorbij het einde. Met range-checking uitgeschakeld worden beide een daadwerkelijke out-of-bounds-toegang in plaats van een opvangbare uitzondering. Omdat de slots referentiegetelde AnsiString-waarden bevatten, kan een verkeerde leesactie ook de referentietelling van een string corrumperen.

De beveiliging heeft range-checking niet opnieuw ingeschakeld voor het kritieke pad. In plaats daarvan zijn de indexen eerst aantoonbaar geldig gemaakt: voordat de interpreter de bovenkant van de operand-stack leest, controleer hij of de stack niet leeg is. Tevens is elke indexcontrole geschreven als een strikte kleiner-dan-vergelijking ten opzichte van het aantal, in plaats van een kleiner-dan-of-gelijk-aan die de off-by-one-fout toestaat. De richtlijn verplaatst de verantwoordelijkheid voor grenzen van de compiler naar u, en de validatie die hiermee werd verwijderd moet handmatig bij elk ingangspunt worden teruggeplaatst.

Onbegrensde recursie in een charstring-interpreter

Een Type2-charstring kan een subroutine aanroepen, en een subroutine is zelf een charstring die weer een andere kan aanroepen. De lokale en globale subroutine-aanroepers zorgen er dus voor dat het bestand bepaalt hoe diep dit gaat. Een subroutine die zichzelf aanroept, rechtstreeks of via een lus, recurseert eindeloos totdat de systeemslag (native stack) uitgeput raakt en het proces stopt. Dat is CWE-674, ongecontroleerde recursie.

De Type1-interpreter was hier al tegen beveiligd. Deze bevinte een aanroepdiepteteller en een plafond, PLType1MaxCallDepth, en weigerde dieper af te dalen. Dit weerspiegelt de dieptelimiet die de Type1-specificatie zelf voorschrijft. De Type2-interpreter, die later is toegevoegd en structureel vergelijkbaar is, weigerde die controle niet, waardoor een handmatig geconstrueerd lettertype met een subroutine die zijn eigen nummer aanroept, direct door het ontbreken van de controle heen liep en een stack-overflow veroorzaakte.

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

De oplossing was om het Type2-pad te voorzien van dezelfde begrensde diepte als zijn Type1-pendant. Elke recursieve afdaling over een structuur onder controle van een aanvaller — of het nu gaat om subroutines van lettertypen, een geneste array of een kruisverwijzingsketen — vereist een diepteplafond dat de invoer niet kan omzeilen.

Niet-geïnitialiseerd geheugen dat naar de uitvoer lekt

De meest subtiele fout lekte heap-inhoud naar de ontsleutelde uitvoer, en de oorzaak hiervan is een eigenschap van SetLength die men gemakkelijk vergeet. Wanneer u een AnsiString vergroot met SetLength, Delphi alloceert de bytes maar wist ze niet met nullen. Het nieuwe gebied bevat dus wat er voorheen in dat heap-geheugen stond. Als elke byte vervolgens wordt geschreven, is dit nooit een probleem; maar als een pad een deel van de buffer ongeschreven laat en dit vervolgens als gegevens retourneert, reizen die verouderde bytes mee met het resultaat. Dat is CWE-457 (gebruik van niet-geïnitialiseerd geheugen), en wanneer het resultaat een vertrouwensgrens overschrijdt, leidt dit tot een informatielek.

Het AES-CBC-ontsleutelingspad stuitte exact hierop. De uitvoerbuffer werd gedimensioneerd met SetLength en de de-cryptor verwerkte de cijfertekst blok voor blok in stappen van 16 bytes. Wanneer de lengte van de cijfertekst geen veelvoud van 16 was (een lengte die een aanvaller kan kiezen), werd het resterende gedeeltelijke blok nooit geschreven. Die laatste bytes behielden daardoor de heap-inhoud die SetLength had achtergelaten en de buffer was geretourneerd als de ontsleutelde klaartekst van een documentobject. De remedie bestaat uit twee beveiligingen, waarvan er geen enkele alleen voldoende is: het ingangspunt van de ontsleuteling weigert nu elke cijfertekst waarvan de lengte geen veelvoud is van de blokgrootte, en als vangnet wordt de uitvoer voor gebruik gewist met FillChar, zodat elk pad dat er niet in slaagt een gebied te schrijven nullen retourneert in plaats van heap-restanten.

Wat het resultaat u biedt

De vijf fouten zijn verschillende bugs, maar ze vertonen gelijkenissen. Een integer-breedte die een product laat wrappen, een veldtype dat een controle vastzet op constant onwaar, een uitgeschakelde range-check waar de indexen niet meer veilig waren, een recursie zonder bodem en een buffer die de taal weigerde te wissen met nullen. In elk geval deed Delphi precies wat het voorschrijft. De taal biedt immers berekeningen die wrappen, stille versmalling, uitschakelbare range-checks, recursie zonder ingebouwde limiet en allocatie die niet initialiseert. Dat is de afspraak, en een Pascal-parser komt deze na door handmatig vier zaken te beheren bij elke grens die het bestand beheert: integer-breedte, range-checking, recursiediepte en bufferinitialisatie.

Deze defecten zijn verholpen in de huidige releases van PDFlibPas, de engine voor Delphi en C++Builder. Als uw werk zich ook uitstrekt tot de wijze waarop een bestand claimt te zijn beveiligd, de bijbehorende artikelen over het controleren van versleuteling en machtigingen en over PDF/A- en PDF/UA-preflight behandelen de analytische kant van dezelfde parser. Dit alles wordt geleverd binnen de PDFlibPas Delphi PDF Library alongside the loading, rendering, and signing APIs covered elsewhere on this blog.