Delsøk som Pos('/Length', Dict) er feil verktøy for å lese en PDF-ordbok, fordi PDF-navnenøkler deler prefikser: /Length er et prefiks for /Length1, og /Encrypt er et prefiks for /EncryptMetadata. ISO 32000-1 §7.3.5 definerer et navn (name) som et token som bare slutter ved en skilletegn- eller mellomroms-karakter, så en nøkkel regnes som funnet bare når byten etter den er en av disse karakterene. En ordboksleser som hopper over denne ene sjekken, vil til slutt lese feil verdi fra en helt gyldig fil
Feilen som lærte oss dette, så ikke ut som et leksikalsk problem i det hele tatt. En samsvarsskanning begynte å rapportere en FontFile-strøm som skadet: det dekomprimerte skriftprogrammet var et fragment, avskåret midt i en tabell. Filen åpnet fint i alle visere. Strømdataene på disken var intakte. Rotårsaken satt i én linje i vår delte ordboksleser: Pos('/Length', ...) hadde matchet /Length1, en standardnøkkel som FontFile-strømordbøker bærer i henhold til ISO 32000-1 Tabell 127, og leseren tok heltallet etter /Length1 som strømlengden. Skriveren av akkurat den filen tilfeldigvis serialiserte /Length1 før /Length, noe den står helt fritt til å gjøre, siden ordbokoppføringer er uordnede i henhold til §7.3.7. Strømmen wurde trunkert til et feilaktig antall byte, og hver nedstrøms sjekk som konsumerte den, ble lydløst blind
Hvorfor ødelegger delstreng-matching tolking av PDF-ordbøker?
Delstreng-matching svikter fordi PDF-navneområdet er fullt av bevisste prefiksfamilier, og fordi rekkefølgen på ordbokoppføringene er uspesifisert. Tabell 127 i ISO 32000-1 definerer /Length1, /Length2 og /Length3 for innebygde skriftstrømmer, som alle sitter ved siden av en /Length i samme ordbok. Krypteringsordboken parer /Encrypt i traileren med /EncryptMetadata inni den. Korte nøkler er verre: et oppslag bygd som Pos('/' + Key, ...) med Key = 'N' lander lykkelig på /Name eller /Nums. Ingen av disse kollisjonene krever en feilaktig fil. En skriver som ordner /Length1 før /Length er helt i samsvar, noe som betyr at delstrengfeilen ikke er et robusthetsgap mot ødelagte inndata — det er et korrekthetsgap mot gyldige inndata
Feilmodusen er også av den stille typen. En feil /Length utløser ikke unntak; den gir deg et kortere eller lengre byte-utsnitt enn strømmen faktisk opptar. Hvis det utsnittet mater en skrift-delmengdesjekk, en CMap-tolking eller en metadataskanning, ser forbrukeren søppel og rapporterer vanligvis ingenting i det hele tatt, fordi en halv zlib-strøm ganske enkelt mislykkes i å dekomprimeres og koden fortsetter. Vi leverte akkurat denne klassen av defekter og fikset den i v2.14.3 av vår delte leser, etter at en klausul-for-klausul-revisjon av ISO 32000-1 §7.2–§7.3 flagget ethvert Pos-stil-nøkkeloppslag som mistenkelig
Hva ISO 32000-1 §7.3.5 faktisk definerer at et navn er
Avsnitt 7.3.5 er kort og presist: et navneobjekt (name object) er en skråstrek (solidus) etterfulgt av en sekvens av vanlige karakterer, og tokenet avsluttes av det første skilletegnet eller mellomromstegnet. Skilnetegnene er de åtte parenteskarakterene pluss skråstreken og prosenttegnet — ( ) < > [ ] { } / % — og mellomrom er null, tabulator, linjeskift, sideskift, vognretur og mellomrom (§7.2.2–§7.2.3). Den avsluttende regelen er hele historien. /Length1 er ikke "/Length etterfulgt av et 1-tall"; det er et enkelt, udelelig token, akkurat som LengthOne og Length are forskjellige identifikatorer i Pascal. Enhver leser som finner nøkler ved rå byte-søk, gjenskaper leksikalsk analyse med avslutningsregelen slettet
Her er formen på defekten, redusert til det essensielle. Denne versjonen kompilerer, består tester mot filer der skrivere ordner /Length først, og ødelegger strømmer for skrivere som ikke gjør det
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Heltoken-matching: sjekk byten etter nøkkelen
Det riktige predikatet følger direkte fra §7.3.5: et treff er en reell nøkkel kun hvis karakteren umiddelbart etter den er et skilletegn, mellomrom eller slutten av bufferen. Alt annet er et lengre navn som bare deler et prefiks, så søket må fortsette forbi det i stedet for å gi opp. Fiksingen i leseren vår erstattet hvert rå Pos-oppslag med en enkelt delt rutine bygd på denne regelen
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
To detaljer i den løkken har vekt. For det første den fortsetter å søke i stedet for å returnere feil ved den første prefikskollisjonen, fordi /Length1 120 /Length 4076 er en lovlig rekkefølge og den reelle nøkkelen fremdeles er lenger fremme. For det andre teller tilfellet med buffer-slutt som en avslutter, ettersom et ordboksfragment legitimt kan ende rett etter et navn. Et mer subtilt poeng som er verdt å revidere i egen kode: den samme regelen gjelder på venstre side av treffet hvis søkestrengen din ikke inkluderer skråstreken, ellers kan Pos('Length', ...) kan lande inni /PieceLength. Å forankre søkestrengen med den ledende /, som over, håndterer den venstre kanten fordi / i seg selv er et skilletegn som avslutter det foregående tokenet
Hvordan kan en fiendtlig PDF gjøre en tolkerfeil om til en gigabyte-allokering?
En feilaktig eller skadelig fil eskalerer disse leksikalske feilene til ressursuttømming, fordi ordbok-heltall ofte mater allokeringsstørrelser. Revisjonen vår fant en kjede med akkurat denne formen i objektstrøm-utvidelse. /N-oppføringen i en ObjStm-ordbok angir hvor mange komprimerte objekter strømmen inneholder, og utvidelseskoden kalte SetLength på en tabell dimensjonert av denne. Heltallstolkeren lot imidlertid ut-parameteren sin forbli urørt ved feil mens den fremdeles returnerte den — så en ikke-numerisk /N ga SetLength en uinitialisert stakkverdi. Et feilaktig positivt heltall der betyr en allokeringsforespørsel i gigabyte-klassen, utløst av noen få byte med korrupte inndata, mens du bare skanner et dokument du ikke engang har samtykket i å stole på ennå
Reparasjonen hadde to uavhengige deler, og begge kan generaliseres. Tolkeren returnerer nå en eksplisitt 0 ved feil, aldri uinitialisert minne. Og forbrukeren stoler ikke lenger på /N uten aritmetikk: ObjStm-overskriftsområdet før /First lagrer et par heltall — objektnummer og avvik — for hvert komprimert objekt, og hvert par opptar minst fire byte inkludert separatorer. Enhver /N over FirstVal div 4 + 1 er derfor fysisk umulig for den deklarerte overskriftsstørrelsen, og avvises før noen allokering skjer. Grensen koster én sammenligning og er avledet fra data man allerede har, noe som er mønsteret man bør se etter: et tak filen selv beviser, ikke en vilkårlig konstant
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
Ytterligere to tak fullfører den defensive barrieren i leseren vår, begge levert i v2.12.0. Strømleseren avviser enhver /Length som er større enn hele filen før den allokerer resultatbufferen — en strøm kan ikke være større enn beholderen den lever i, så sjekken er fri for falske positive. Og inflate-banen begrenser dekomprimerte utdata til 256 MiB, noe som stopper den klassiske zlib-bomben der noen få kilobyte med inndata ekspanderer uten grenser; grensen er sjenerøs for enhver reell PDF-strøm mens den holder det verste tilfellet overlevbart. Temaet på tvers av alle tre er det samme: enhver størrelse en fil deklarerer er en påstand, og tolkeren verifiserer hver påstand mot noe den kan måle før den forpliktet minne til den. Den samme revisjonsholdningen gjelder ett lag lenger ned ved bindingsgrensen, som dekkes i herding av PDFium-ABI-et og minnesikkerhet i Delphi
Der heltoken-regelen ikke er nok
Ærlige grenser, slik at du ikke stoler for mye på rutinen over. Heltoken-matching retter opp nøkkelidentifisering, men et flatt byte-søk over et ordboksspenn kan fremdeles ikke fortelle om et treff sitter inni en nøstet ordbok, en bokstavelig streng eller en kommentar — FindDictKey på et sideobjekt kan lande på en nøkkel inni dens /Resources-underordbok hvis du gir den et for bredt spenn. Leseren vår begrenser spennet til en enkelt objektkropp først og behandler streng- og kommentarkontekster som et separat, fremdeles åpent revisjonspunkt. Delstreng-sikkerhet er ett trinn på en stige, ikke hele stigen: konsistens i kryssreferanser er en egen disiplin, dekket i validering av objekt- og xref-strømmer, og den bredere trussel-katalogen for dokumenter du ikke har skrevet selv er i revisjon av PDF-sikkerhetsrisikoer
Hvis du vedlikeholder en håndskrevet ordboksleser i Delphi or Lazarus, er sjekklisten fra denne hendelsen er kort. Gjør grep for hver Pos('/ i kodebasen og rut treffene gjennom én heltoken-hjelper. List opp prefiksfamiliene nøklene dine deltar i — /Length, /Encrypt, /N, /Type mot /Type1 dukker alle opp i reelle filer. Gå deretter gjennom hvert heltall som når SetLength, GetMem eller en kopieringsløkke, og spør hva som begrenser det: filstørrelsen, et overskriftsavledet tak, eller ingenting. Tolkningslaget beskrevet her er fundamentet under vår PDFium Component, der leseren på bytenivå og tegningsmotoren kryssjekker hverandre på hvert dokument de berører