Articolo tecnico

Analisi sicura dei dizionari PDF in Delphi: Token di nome

Le ricerche di sotto-stringhe come Pos('/Length', Dict) sono lo strumento sbagliato per leggere un dizionario PDF, poiché le chiavi dei nomi PDF condividono prefissi comuni: ad esempio, /Length è un prefisso di /Length1, e /Encrypt è un prefisso di /EncryptMetadata. Lo standard ISO 32000-1 §7.3.5 definisce un nome come un token che termina esclusivamente in corrispondenza di un delimitatore o di uno spazio vuoto (whitespace), per cui una chiave è considerata individuata solo quando il byte successivo è uno di tali caratteri. Un lettore di dizionari che omette questo singolo controllo finirà per leggere un valore errato da un file altrimenti perfettamente valido

Il bug che ci ha mostrato questo problema non sembrava affatto un errore del lexer. Una scansione di conformità ha iniziato a segnalare un flusso FontFile come danneggiato: il programma di font decompresso era un frammento, interrotto a metà tabella. Il file si apriva correttamente in qualsiasi visualizzatore. I dati del flusso su disco erano integri. La causa principale risiedeva in una riga del nostro lettore di dizionari condiviso: Pos('/Length', ...) aveva trovato corrispondenza con /Length1, una chiave standard contenuta nei dizionari di flusso FontFile secondo la Tabella 127 dello standard ISO 32000-1, e il lettore ha interpretato l'intero successivo a /Length1 come lunghezza del flusso. Il modulo di scrittura di quel file specifico aveva serializzato /Length1 prima di /Length, operazione del tutto legittima poiché le voci del dizionario non sono ordinate ai sensi della sezione §7.3.7. Il flusso è stato troncato a un numero di byte errato e ogni successivo controllo che lo utilizzava ha fallito silenziosamente

Perché la corrispondenza delle sotto-stringhe compromette l'analisi dei dizionari PDF?

La corrispondenza delle sotto-stringhe fallisce perché lo spazio dei nomi PDF è ricco di famiglie di prefissi intenzionali, e perché l'ordine delle voci nei dizionari non è specificato. La Tabella 127 dello standard ISO 32000-1 definisce /Length1, /Length2 e /Length3 per i flussi di font incorporati, tutti collocati accanto a una chiave /Length nello stesso dizionario. Il dizionario di crittografia associa /Encrypt nel trailer a /EncryptMetadata al suo interno. Le chiavi corte presentano criticità maggiori: una ricerca strutturata come Pos('/' + Key, ...) con Key = 'N' può facilmente corrispondere a /Name o /Nums. Nessuna di queste collisioni richiede un file malformato. Un modulo di scrittura che ordina /Length1 prima di /Length is pienamente conforme, il che significa che il bug delle sotto-stringhe non è un difetto di robustezza contro input corrotti, bensì un errore di correttezza nell'elaborazione di input validi

Inoltre, questa modalità di errore è di tipo silenzioso. Un valore /Length errato non solleva un'eccezione; si limita a restituire una porzione di byte più corta o più lunga di quella effettivamente occupata dal flusso. Se tale porzione alimenta una verifica del sottoinsieme di font, un'analisi CMap o una scansione di metadati, l'utilizzatore riceve dati non validi e solitamente non segnala nulla, poiché una parte di un flusso zlib semplicemente non si decomprime e il codice prosegue. Abbiamo distribuito esattamente questa classe di difetto, correggendola nella versione v2.14.3 del nostro lettore condiviso, dopo che una verifica dettagliata di ISO 32000-1 §7.2–§7.3 ha segnalato come sospetta ogni ricerca di chiavi basata su Pos

Cosa definisce effettivamente come nome la sezione ISO 32000-1 §7.3.5

La sezione 7.3.5 è breve e precisa: un oggetto nome è una barra (solidus) seguita da una sequenza di caratteri regolari, e il token è terminato dal primo delimitatore o carattere di spazio vuoto. I delimitatori sono gli otto caratteri di parentesi più la barra e il segno di percentuale — ( ) < > [ ] { } / % — e gli spazi vuoti sono i caratteri null, tabulazione, avanzamento riga (line feed), avanzamento pagina (form feed), ritorno a capo (carriage return) e spazio (§7.2.2–§7.2.3). Questa regola di terminazione definisce l'intero comportamento. /Length1 non è "/Length seguito da un 1"; si tratta di un unico token indivisibile, esattamente come LengthOne e Length sono identificatori diversi in Pascal. Qualsiasi lettore che ricerchi le chiavi tramite una scansione di byte grezzi sta reinstallando il lexer privandolo della regola di terminazione

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

Corrispondenza dell'intero token: controllare il byte dopo la chiave

Il predicato corretto deriva direttamente da §7.3.5: una corrispondenza candidata è una vera chiave solo se il carattere immediatamente successivo ad essa è un delimitatore, uno spazio vuoto o la fine del buffer. Qualsiasi altro valore rappresenta un nome più lungo che condivide semplicemente il prefisso, per cui la ricerca deve proseguire oltre di esso anziché interrompersi. La correzione nel nostro lettore ha sostituito ogni ricerca Pos grezza con una singola routine condivisa basata su questa regola

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;

Due dettagli in questo ciclo meritano attenzione. In primo luogo, la ricerca prosegue invece di restituire un errore alla prima collisione di prefisso, poiché la sequenza /Length1 120 /Length 4076 è un ordinamento valido e la vera chiave si trova più avanti. In secondo luogo, il caso di fine buffer è considerato un terminatore, poiché un frammento di dizionario può legittimamente concludersi subito dopo un nome. Un aspetto più sottile da verificare nel proprio codice: la stessa regola si applica sul lato sinistro della corrispondenza se la stringa di ricerca non include la barra iniziale, altrimenti Pos('Length', ...) potrebbe corrispondere ad elementi interni a /PieceLength. Ancorare la stringa di ricerca con la barra / iniziale, come mostrato sopra, gestisce il margine sinistro poiché la barra è essa stessa un delimitatore che conclude il token precedente

In che modo un PDF ostile può trasformare un bug del parser in un'allocazione di gigabyte?

Un file malformato o dannoso trasforma questi errori lessicali in esaurimento delle risorse, poiché gli interi dei dizionari definiscono spesso le dimensioni delle allocazioni di memoria. Il nostro controllo ha rilevato una catena proprio di questa forma nell'espansione dei flussi di oggetti (object streams). La voce /N di un dizionario ObjStm indica quanti oggetti compressi sono contenuti nel flusso, e il codice di espansione richiamava SetLength su un array dimensionato in base ad esso. Il parser degli interi, tuttavia, lasciava inalterato il suo parametro di output in caso di errore pur restituendolo comunque — per cui un valore /N non numerico passava a SetLength un valore dello stack non inizializzato. Un intero positivo errato si traduce in una richiesta di allocazione di svariati gigabyte, attivata da pochi byte di input corrotto, anche solo analizzando un documento prima che venga confermata la sua affidabilità

La correzione si è articolata in due parti indipendenti, entrambe con valenza generale. Il parser restituisce ora uno 0 esplicito in caso di errore, evitando memoria non inizializzata. Inoltre, il modulo consumatore non si affida più a /N senza calcoli preventivi: la regione di intestazione ObjStm prima di /First memorizza una coppia di interi — numero dell'oggetto e offset — per ciascun oggetto compresso, e ogni coppia occupa almeno quattro byte compresi i separatori. Qualsiasi valore /N superiore a FirstVal div 4 + 1 è quindi fisicamente impossibile per la dimensione di intestazione dichiarata e viene rifiutato prima che avvenga qualsiasi allocazione. Questo limite richiede un unico confronto e deriva da dati già disponibili, che è lo schema ideale da seguire: un tetto massimo dimostrato dal file stesso, anziché una costante arbitraria

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

Altri due limiti di sicurezza completano il perimetro difensivo del nostro lettore, entrambi introdotti nella versione v2.12.0. Il lettore del flusso rifiuta qualsiasi valore /Length superiore all'intero file prima di allocare il buffer dei risultati — un flusso non può essere più grande del contenitore in cui risiede, per cui il controllo è privo di falsi positivi. Inoltre, il percorso di decompressione limita l'output decompresso a 256 MiB, neutralizzando i classici file dannosi di tipo zlib bomb in cui pochi kilobyte di input si espandono a dismisura; questo limite è generoso per qualsiasi flusso PDF reale, pur rendendo gestibile il caso peggiore. Il principio di base per tutti e tre i controlli è il medesimo: ogni dimensione dichiarata da un file è un'asserzione, e il parser verifica tale asserzione rispetto a elementi misurabili prima di allocare memoria per essa. Lo stesso approccio si applica un livello più in basso al confine dei binding, trattato nell'irrobustimento dell'ABI di PDFium e sicurezza della memoria in Delphi

Dove la regola dell'intero token non è sufficiente

Limiti oggettivi, per non riporre eccessiva fiducia nella routine sopra descritta. La corrispondenza dell'intero token corregge l'identificazione della chiave, ma una ricerca di byte semplici su un'estensione di dizionario non è comunque in grado di rilevare se una corrispondenza risieda all'interno di un dizionario annidato, di una stringa letterale o di un commento — FindDictKey su un oggetto pagina può individuare una chiave all'interno del suo sotto-dizionario /Resources se si elabora un'estensione troppo ampia. Il nostro lettore limita innanzitutto l'analisi al corpo di un singolo oggetto e tratta i contesti di stringhe e commenti como elementi di verifica separati e ancora da definire. La sicurezza delle sotto-stringhe è solo un gradino, non l'intera scala: la coerenza dei riferimenti incrociati è una disciplina a sé stante, trattata nella convalida degli oggetti e dei flussi xref, e la classificazione più ampia dei rischi di sicurezza per i documenti di origine esterna è presente nell'analisi dei rischi di sicurezza dei PDF

Se gestisci un lettore di dizionari scritto a mano in Delphi o Lazarus, l'elenco delle attività derivanti da questo problema è breve. Cerca ogni occorrenza di Pos('/' in delphi o lazarus) nel codice e instradane i risultati attraverso un unico helper per l'intero token. Elenca le famiglie di prefissi in cui compaiono le tue chiavi — ad esempio /Length, /Encrypt, /N, /Type rispetto a /Type1 si trovano tutti nei file reali. Quindi verifica ogni intero che raggiunge SetLength, GetMem o un ciclo di copia e analizzane i limiti: la dimensione del file, un tetto massimo derivato dall'intestazione, o nessun vincolo. Il livello di analisi qui descritto costituisce la base del nostro Componente PDFium, in cui il lettore a livello di byte e il motore di rendering eseguono controlli incrociati su ogni documento trattato