Een document-intake-pijplijn accepteert bestanden die door vreemden zijn geschreven. Facturen, scans, bijlagen van een webformulier: elk beweert een PDF te zijn en bevat honderden getallen waarop je parser geacht wordt te reageren. Streamlengtes, afbeeldingsdimensies, byte-offsets, objectverwijzingen — stuk voor stuk gekozen door degene die het bestand heeft geproduceerd, en een afgekapte upload of een opzettelijk misvormd document zal uiteindelijk een van die getallen plaatsen waar het schade aanricht. Het verschil tussen een parser die dat bestand overleeft en een die crasht, of blijft draaien met gecorrumpeerd geheugen, is een kleine verzameling gewoonten die niet afhankelijk zijn van een specifieke PDF-bibliotheek
Deze gewoonten delen één uitgangspunt: een waarde die uit het bestand wordt gelezen is een claim, geen meting. Het wordt pas bruikbaar nadat het is gecontroleerd aan de hand van iets dat de parser zelf heeft gemeten — de werkelijke grootte van het bestand, het werkelijke aantal bytes dat een decoder heeft geproduceerd, de werkelijke diepte van een recursie. Wat volgt is dat uitgangspunt toegepast op de plekken waar documentparsers daadwerkelijk stukgaan
Een opgegeven lengte is een claim, geen meting
De eenvoudigste mismatch is de streamlengte. Een PDF-streamobject verklaart het aantal bytes in de /Length-sleutel, en de werkelijke data bevindt zich tussen de stream- en endstream-sleutelwoorden. Niets dwingt die twee om met elkaar overeen te komen. Een afgekapt bestand bevat minder werkelijke bytes dan de opgegeven telling; een bestand van een defecte generator kan een lengte declareren die voorbij het einde van het bestand of tot in een aangrenzend object reikt. Wijs toe vanaf de gedeclareerde waarde en kopieer tot endstream en je overschrijdt de buffer; lees exact de gedeclareerde telling zonder de beschikbaarheid te controleren en je loopt van het einde van het bestand af. Laat de opgegeven waarde alleen de toewijzing sturen nadat je deze hebt vastgezet tegen de gemeten afstand tot het einde van de data, en behandel een verschil als een beslissingspunt — repareer door te scannen naar endstream, of verwerp de stream — nooit als iets om stilzwijgend te geloven
Afbeeldingsparameters die een groter raster beschrijven dan je hebt toegewezen
Afbeeldingsstreams verhogen de inzet omdat twee onafhankelijke sets getallen dezelfde pixels beschrijven. De afbeeldingsdictionary bevat /Width en /Height, en rasterbuffers worden daar meestal op afgestemd. Het decodeerfilter draagt zijn eigen geometrie: CCITTFaxDecode haalt /Columns, /Rows en /K uit zijn DecodeParms, waarbij /K het Group 3- of Group 4-schema selecteert en de decoder (Columns + 7) div 8 bytes per scanlijn afgeeft. Een bestand dat /Width 100 declareert, maar het filter /Columns 1728 — de standaardwaarde — aanreikt, laat de decoder ruim zestien keer de bytes per rij produceren die de buffer verwacht, en de overflow belandt één scanlijn tegelijk in wat er ook maar na de toewijzing staat. Wanneer /Rows ontbreekt, loopt de decoder door totdat de gegevens zeggen dat hij moet stoppen, dus begrens ook het aantal rijen. DCTDecode kent dezelfde naad: de JPEG-data draagt zijn eigen breedte en hoogte in de SOF-markering, en niets verplicht ze om overeen te komen met de dictionary
De defensieve regel is mechanisch: bereken de verwachte rastergrootte uit de gevalideerde decodeerparameters — de eigen /Columns en /Rows van het filter voor CCITT, de SOF-dimensies voor DCT — controleer het aan de hand van je limieten, wijs van daaruit toe en verifieer tijdens de decodering dat de uitvoer nooit de toewijzing overschrijdt. Wanneer de dictionary en het filter het oneens zijn over de geometrie, stem ze dan op elkaar af of verwerp de afbeelding. Wat een parser nooit mag doen, is de buffer dimensioneren op basis van de ene set getallen en de decoder laten draaien op de andere
Valkuilen in Delphi-rekenkunde en toewijzing
Drie Delphi-gedragingen ondermijnen zelfs een parser die van plan is te valideren. De eerste is 32-bit vermenigvuldiging: Delphi evalueert het product van twee Integer operanden op 32 bits, ongeacht de breedte van de bestemming, dus Width * Height * BytesPerPixel kan 'wrappen' (omdraaien), zelfs wanneer elke factor zijn eigen geldigheidscontrole doorstaat. Een scan van 30.000 bij 30.000 met drie bytes per pixel is 2,7 miljard bytes, wat negatief omklapt in ondertekende (signed) 32-bit rekenkunde; enigszins verschillende factoren klappen om naar een kleine positieve lengte die de buffer toewijst en onderdimensioneert. Forceer de hele uitdrukking breed (wide) door de eerste operand te casten — Size := Int64(Width) * Height * BytesPerPixel — en vergelijk het vervolgens met een expliciete limiet voordat er ook maar iets SetLength bereikt
De tweede is bereikcontrole (range checking). Delphi's standaard releaseconfiguratie wordt geleverd met dit uitgeschakeld, dus een index buiten het bereik die is berekend op basis van bestandsgegevens werpt geen fout op — het leest of schrijft in geheugen dat grenst aan de array. Zet het weer aan met {$R+} (en {$Q+} voor rekenkundige overflow) bovenaan elke unit die indexeert met waarden afkomstig uit bestanden. De kosten zijn onmeetbaar naast de I/O die een parser sowieso al doet, en het converteert stille corruptie naar een opvangbare ERangeError
De derde is TMemoryStream.SetSize met een door het bestand geleverde Int64. Op een huidige RTL wijst het toe wat het bestand ook maar vroeg, dus een enkele stream die vier gigabyte claimt, wordt een 'out-of-memory' storing midden in de intake. Op oudere RTL's, waar SetSize een Longint accepteert, wordt de waarde eerst stilzwijgend vernauwd: een gedeclareerde $100000010 wordt 16, de toewijzing slaagt, en het schrijven van de daadwerkelijke gegevens loopt er ver voorbij. Valideer elke grootte tegen de gemeten brongrootte en een harde limiet voordat enige toewijzingsaanroep deze ziet
Offsets die buiten het bestand wijzen
De kruisverwijzingstabel (cross-reference table) wijst objectnummers toe aan absolute byte-offsets, en de parser zoekt waarheen deze wijst. In een beschadigd of vijandig bestand landen die offsets voorbij het einde van het bestand of binnen niet-gerelateerde structuren. TStream maakt de fout stil: Position voorbij Size instellen is geen fout, en een simpele Read voorbij het einde retourneert gewoon minder bytes dan gevraagd, dus code die de tellingscontrole overslaat blijft oude bytes van het vorige object parseren. De verdediging is een knelpunt (chokepoint) — één helper waar elke door een bestand gestuurde zoekopdracht en leesactie doorheen gaat, waarbij offset en telling worden gevalideerd tegen de gemeten bestandsgrootte voordat de stream beweegt
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB
type
EPdfBoundsError = class(Exception);
// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'object extent %d+%d exceeds file size %d',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
Leid kruisverwijzing-offsets, stream-omvang en het inlezen van ingesloten bestanden erdoorheen, en een foute offset wordt een schone afwijzing die de nummers noemt in plaats van een toegangsovertreding (access violation) drie aanroepen later
Cycli en diepte in de objectgrafiek
Een PDF is een grafiek, geen boom. Elke waarde kan een indirecte verwijzing zijn, een verwijzing kan naar een andere verwijzing leiden — /Length 12 0 R, waarbij object 12 13 0 R bevat — en niets voorkomt dat een keten zich op zichzelf sluit. Een resolver die verwijzingen naïef volgt, zal recursief doorgaan totdat de native stack uitgeput is, en stackuitputting is niet iets dat je opvangt; het beëindigt het proces. Diep geneste arrays en dictionary's bereiken hetzelfde einde zonder enige cyclus
Gebruik twee bewakers (guards) samen: een expliciete diepteteller begrensde het eerlijke-maar-diepe geval op een limiet die geen enkel legitiem bestand benadert, en een bezochte set (visited set) vangt een echte cyclus op bij zijn tweede bezoek, waardoor het in een nauwkeurige, rapporteerbare fout verandert in plaats van een limiet-uitschakeling
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // meaningful when Kind = pvReference
// ... payload fields for the remaining kinds
end;
// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // e.g. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // siblings may legally share this object
end;
end;
Decompressie is een versterker
Een paar kilobytes aan FlateDecode-invoer kan opblazen tot gigabytes; compressie voor algemene doeleinden beloont repetitieve platte tekst, en een aanvaller kan het maximaal repetitief maken. Beperk de opgeblazen omvang van elke stream tot wat de consument aannemelijk nodig kan hebben, en houd een tweede budget per document bij: vijfhonderd streams die stuk voor stuk net onder de streamlimiet zitten, putten het geheugen net zo zeker uit als één gigantische stream. De controle hoort thuis binnenin de inflatielus, waarbij uitvoerbytes worden geteld naarmate ze worden geproduceerd en wordt afgebroken bij een overschrijding, niet na de lus wanneer het geheugen al is verbruikt. Een documentbudget uitgedrukt als een veelvoud van de gecomprimeerde bestandsgrootte werkt goed, aangezien legitieme documenten zich ver onder de verhoudingen clusteren die een gemanipuleerde stream bereikt
Defensie in de diepte (defense in depth) buiten je eigen units
Dezelfde foutklassen leven binnen bibliotheken. Twee casestudy's op deze blog doorlopen echte gevallen: de integer-wraps, onbegrensde recursie en niet-geïnitialiseerde buffers die werden gesloten in een native Pascal-engine in Het verharden van een Pascal PDF-parser tegen kwaadaardige bestanden, en de aanroepconventie, integer-breedte, en eigendomsrisico's (ownership hazards) van het koppelen van een C-engine in Het verharden van een PDFium Component Binding. Voor een oprecht onvertrouwde intake — een openbaar uploadformulier, een ongeauthenticeerde mailbox — voer je het parseer- en decodeerwerk ook uit in een afzonderlijk proces met weinig privileges, zodat het bestand dat elke in-process bewaker verslaat een mislukte taak kost in plaats van een neergehaalde dienst
Een preflight checklist
Voordat de volgende build (build) wordt uitgebracht, doorloop je de parser langs deze lijst: elke streambuffer gedimensioneerd vanaf een vastgezette lengte in plaats van de gedeclareerde; elk raster gedimensioneerd op basis van gevalideerde decodeerparameters en gecontroleerd tegen de decoderuitvoer; elk dimensieproduct geëvalueerd in Int64 en vergeleken met een expliciete limiet; {$R+} actief in elke unit die indexeert met waarden afgeleid van het bestand; elke zoekopdracht met grenzen gecontroleerd tegen de gemeten bestandsgrootte; elke referentieoplossing met beperkte diepte en gecontroleerd op cycli; elke inflatielus die uitvoer telt tegen de budgetten per stream en per document. Geen van deze controles kost meetbare tijd op een legitiem document, en elk ervan converteert geheugencorruptie naar een schone, logbare afwijzing
Opmerking: de losLab HotPDF Component, PDFlibPas Delphi PDF-bibliotheek, en PDFium Component passen deze grenscontroles, dieptelimieten en uitbreidingslimieten intern toe, dus een intake-pijplijn die daarop is gebouwd start vanaf een geharde basislijn