Articol tehnic

Analiza sigură a dicționarelor PDF în Delphi: Tokeni de nume

Căutările de subșiruri cum ar fi Pos('/Length', Dict) sunt instrumentul greșit pentru citirea unui dicționar PDF, deoarece cheile de nume PDF partajează prefixe: /Length este un prefix al /Length1, iar /Encrypt este un prefix al /EncryptMetadata. ISO 32000-1 §7.3.5 definește un nume ca fiind un token care se termină numai la un delimitator sau la un spațiu gol (whitespace), prin urmare o cheie este considerată găsită doar atunci când octetul de după ea este unul dintre aceste caractere. Un cititor de dicționare care omite această singură verificare va citi în cele din urmă o valoare greșită dintr-un fișier perfect valid

Eroarea care ne-a învățat acest lucru nu semăna deloc cu o problemă de analizator lexical (lexer). O scanare de conformitate a început să raporteze un flux FontFile ca fiind deteriorat: programul de fonturi decomprimat era un fragment, întrerupt la mijlocul tabelului. Fișierul se deschidea corect în orice vizualizator. Datele fluxului de pe disc erau intacte. Cauza principală se afla într-o singură linie a cititorului nostru comun de dicționare: Pos('/Length', ...) se potrivise cu /Length1, o cheie standard pe care dicționarele de fluxuri FontFile o conțin conform Tabelului 127 din ISO 32000-1, iar cititorul a considerat întregul de după /Length1 ca fiind lungimea fluxului. Instrumentul de scriere al acelui fișier a serializat /Length1 înaintea /Length, ceea ce este complet liber să o facă, deoarece intrările din dicționar nu sunt ordonate conform §7.3.7. Fluxul a fost trunchiat la un număr de octeți eronat, iar orice verificare ulterioară care l-a consumat a eșuat în mod silențios

De ce potrivirea subșirurilor afectează analiza dicționarelor PDF?

Potrivirea subșirurilor eșuează deoarece spațiul numelor PDF este plin de familii de prefixe deliberate și deoarece ordinea intrărilor în dicționar este nespecificată. Tabelul 127 din ISO 32000-1 definește /Length1, /Length2 și /Length3 pentru fluxurile de fonturi încorporate, toate aflate alături de un /Length în același dicționar. Dicționarul de criptare asociază /Encrypt din trailer cu /EncryptMetadata din interiorul său. Cheile scurte sunt și mai problematice: o căutare structurată ca Pos('/' + Key, ...) cu Key = 'N' ajunge cu ușurință la /Name sau /Nums. Niciuna dintre aceste coliziuni nu necesită un fișier malformat. Un program de scriere care pune /Length1 înaintea /Length este pe deplin conform, ceea ce înseamnă că eroarea subșirului nu este o lipsă de robustețe la intrările defecte — ci o problemă de corectitudine la datele valide

Modul de eșec este, de asemenea, de tipul silențios. Un /Length greșit nu generează o excepție; vă oferă doar o porțiune de octeți mai scurtă sau mai lungă decât cea pe care o ocupă fluxul în realitate. Dacă acea porțiune alimentează o verificare de subset de fonturi, o analiză CMap sau o scanare de metadate, codul consumator vede date corupte și, de obicei, nu raportează nimic, deoarece jumătate dintr-un flux zlib pur și simplu nu se poate decomprima, iar codul merge mai departe. Am livrat exact această clasă de defecte și am remediat-o în versiunea v2.14.3 a cititorului nostru comun, după ce un audit clauză cu clauză al ISO 32000-1 §7.2–§7.3 a marcat orice căutare de cheie de tip Pos ca fiind suspectă

Ce definește de fapt ISO 32000-1 §7.3.5 ca fiind un nume

Secțiunea 7.3.5 este scurtă și precisă: un obiect nume este un solidus urmat de o secvență de caractere obișnuite, iar tokenul este terminat de primul delimitator sau caracter de spațiu. Delimitatorii sunt cele opt caractere de paranteze, plus solidus-ul și semnul procent — ( ) < > [ ] { } / % —, iar spațiul gol (whitespace) reprezintă caracterele null, tab, line feed, form feed, carriage return și spațiu (§7.2.2–§7.2.3). Acea regulă de terminare reprezintă întreaga logică. /Length1 nu este „/Length urmat de un 1”; este un singur token indivizibil, exact așa cum LengthOne și Length sunt identificatori diferiți în Pascal. Orice cititor care găsește chei prin căutare brută de octeți reimplementează analizatorul lexical cu regula de terminare eliminată

Iată forma defectului, redusă la elementele esențiale. Această versiune se compilează, trece testele pe fișiere unde programul de scriere ordonează /Length mai întâi, dar corupe fluxurile pentru programele care nu fac acest lucru

// GREȘIT: se potrivește și cu /Length1, /Length2, /Length3
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;

Potrivirea tokenului întreg: verificați octetul de după cheie

Predicatul corect rezultă direct din §7.3.5: o potrivire candidată este o cheie reală doar dacă caracterul imediat următor este un delimitator, un spațiu sau sfârșitul bufferului. Orice altceva reprezintă un nume mai lung care doar partajează un prefix, așa că căutarea trebuie să continue dincolo de el în loc să se oprească. Remedierea din cititorul nostru a înlocuit fiecare căutare brută Pos cu o singură rutină comună bazată pe această regulă

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Corect: potrivirea tokenului întreg conform 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);                       // tokenul se termină aici: cheie reală
    P := PosEx(Key, Dict, P + 1);    // prefix al unui nume mai lung: continuă căutarea
  end;
end;

Două detalii din acea buclă sunt importante. În primul rând, căutarea continuă în loc să returneze eșec la prima coliziune de prefix, deoarece /Length1 120 /Length 4076 este o ordonare legală, iar cheia reală se află mai departe. În al doilea rând, cazul de sfârșit de buffer contează ca un terminator, deoarece un fragment de dicționar se poate termina în mod legitim imediat după un nume. Un punct mai subtil ce merită auditat în propriul cod: aceeași regulă se aplică și pe partea stângă a potrivirii dacă șirul căutat nu include solidus-ul, altfel Pos('Length', ...) poate ajunge în interiorul /PieceLength. Ancorarea șirului căutat cu caracterul inițial /, ca mai sus, gestionează marginea stângă deoarece / este el însuși un delimitator care termină tokenul anterior

Cum poate un PDF ostil să transforme o eroare de parser într-o alocare de gigaocteți?

Un fișier malformat sau malițios amplifică aceste greșeli lexicale în epuizarea resurselor, deoarece numerele întregi din dicționar alimentează frecvent dimensiunile de alocare. Auditul nostru a găsit un lanț exact de această formă în extinderea fluxului de obiecte (object-stream). Intrarea /N a unui dicționar ObjStm precizează câte obiecte comprimate conține fluxul, iar codul de extindere apela SetLength pe un tablou dimensionat de acesta. Parserul de numere întregi, însă, lăsa parametrul său de ieșire (out-parameter) nemodificat la eșec, deși îl returna — astfel încât un /N non-numeric trimitea către SetLength o valoare de stivă neinițializată. Un număr întreg pozitiv eronat în acea zonă înseamnă o cerere de alocare de ordinul gigaocteților, declanșată de câțiva octeți de date de intrare corupte, chiar în timpul scanării unui document pe care nu l-ați acceptat încă ca fiind de încredere

Remedierea a avut două părți independente, ambele fiind generalizabile. Parserul returnează acum un 0 explicit la eșec, niciodată memorie neinițializată. Iar codul consumator nu mai are încredere orbească în /N: regiunea de antet ObjStm dinaintea /First stochează o pereche de numere întregi — numărul obiectului și offset-ul — pentru fiecare obiect comprimat, iar fiecare pereche ocupă cel puțin patru octeți, inclusiv separatorii. Orice /N mai mare decât FirstVal div 4 + 1 este prin urmare imposibil din punct de vedere fizic pentru dimensiunea declarată a antetului și este respins înainte de a avea loc vreo alocare. Această limitare costă o singură comparare și este derivată din date deja disponibile, ceea ce reprezintă modelul de urmat: o limită maximă pe care fișierul însuși o dovedește, nu o constantă arbitrară

// /N este controlat de atacator; limitați-l prin ceea ce poate deține /First
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // zero explicit, niciodată date reziduale din stivă
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // antetul nu poate conține atât de multe perechi

// /Length nu poate depăși niciodată fișierul care conține fluxul
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuză înainte de alocarea bufferului

Unde regula tokenului întreg nu este suficientă

Limite oneste, pentru a nu avea încredere oarbă în rutina de mai sus. Potrivirea tokenului întreg rezolvă identificarea cheii, însă o căutare brută de octeți pe o porțiune de dicționar nu poate spune dacă o potrivire se află în interiorul unui dicționar cuibărit, a unui șir literal sau a unui comentariu — FindDictKey pe un obiect pagină poate găsi o cheie în subdicționarul său /Resources subdictionary dacă îi transmiteți o porțiune prea mare. Cititorul nostru limitează mai întâi porțiunea la corpul unui singur obiect și tratează contextele de șiruri și comentarii ca pe elemente de audit separate, încă deschise. Sigranța subșirurilor este doar o treaptă dintr-o scară, nu întreaga scară: consistența referințelor încrucișate este o disciplină în sine, tratată în validarea obiectelor și a fluxurilor xref, iar catalogul extins de amenințări pentru documentele pe care nu le-ați creat dvs. se află în auditarea riscurilor de securitate PDF

Dacă mențineți un cititor de dicționare scris manual în Delphi sau Lazarus, lista de verificare în urma acestui incident este scurtă. Căutați toate instanțele de Pos('/ în cod și direcționați-le printr-un helper de tip token întreg. Listați familiile de prefixe din care fac parte cheile dvs. — /Length, /Encrypt, /N, /Type față de /Type1 apar toate în fișiere reale. Apoi parcurgeți fiecare număr întreg care ajunge la SetLength, GetMem sau într-o buclă de copiere și analizați ce îl limitează: dimensiunea fișierului, o valoare maximă derivată din antet sau nimic. Stratul de analiză descris aici reprezintă fundația componentei noastre PDFium Component, unde cititorul la nivel de octet și motorul de randare se verifică reciproc la fiecare document accesat