Articolo tecnico

Protezione di un parser PDF Pascal contro file dannosi

Un PDF non è un semplice documento che viene aperto. È un piccolo programma eseguito dal sistema. Ogni font incorporato è un interprete basato su stack che attende stringhe di caratteri (charstring), ogni immagine è gestita da un decodificatore che riceve campi di larghezza, altezza e profondità dei bit definiti nel file, e ogni stream giunge racchiuso in filtri i cui parametri sono definiti all'interno del file stesso. Nessuno di questi valori è controllato dall'utente. Provengono dal generatore del file, che in contesti reali può essere la fattura di un cliente o un allegato inviato da uno sconosciuto. I decodificatori che convertono questi byte in pixel e glifi rappresentano la superficie di attacco, e un parser che considera attendibili tali input rischia blocchi o crash in presenza di file malformati

PDF Library for Delphi tratta l'intero percorso di decodifica come ostile, attraverso i programmi dei font (TrueType, Type1, CFF e le tabelle CMap), i decodificatori di immagini (PNG, GIF, TIFF, JBIG2 e CCITT Gruppo 3 e Gruppo 4) e i filtri di stream (LZW, ASCII85 e i predittori Flate). Quella che segue sono cinque trappole di memory safety che quel percorso deve chiudere, ciascuna radicata nel comportamento specifico di Delphi che la rende possibile, e le stesse forme si ripresentano in qualsiasi codice Pascal che analizza input non attendibile

PDF Library for Delphi: mappa della superficie d'attacco di un parser PDF Pascal che alimenta interpreti di font, decoder di immagini e filtri di stream verso cinque classi di trappole presidiate
Larghezze, profondità, offset, lunghezze e flag dei filtri arrivano tutti dal produttore, il che fa dei decoder di font, immagini e filtri la superficie d'attacco che un parser deve trattare come ostile

Un overflow di interi che restituisce un buffer sottodimensionato

Il classico bug di sicurezza della memoria in un decodificatore di immagini è rappresentato dall'overflow nel calcolo delle dimensioni. Un decodificatore legge larghezza, altezza, conteggio dei componenti e profondità dei bit, moltiplicandoli per dimensionare l'output, alloca la quantità di byte risultante e scrive l'immagine alle sue dimensioni reali. Se la moltiplicazione viene eseguita con aritmetica a 32 bit, il prodotto può subire un overflow restituendo un valore piccolo anche se i singoli fattori rientrano in limiti corretti; l'allocazione viene completata ma il buffer risulterà sottodimensionato, portando a una scrittura fuori dai limiti del buffer (out-of-bounds write) nella fase successiva. Questo scenario corrisponde a CWE-190 (integer overflow) che causa CWE-787 (heap out-of-bounds write)

Limitare ciascuna dimensione a 65535 su un percorso immagini condiviso non basta quando un decodificatore autonomo calcola le proprie dimensioni. Un'espressione byte-per-riga per altezza come ByteCount * FHeight, o un'espressione per pixel come FWidth * Components * BitDepth, è un prodotto a 32 bit in Delphi quando entrambi gli operandi sono interi a 32 bit, indipendentemente da quanto è larga la variabile a cui assegni il risultato. Una larghezza e un'altezza di 60000 sono ognuna plausibile per una scansione grande, ma il loro prodotto in byte supera l'intervallo a 32 bit con segno e la lunghezza esce piccola. La stessa trappola si trova in uno stride del predittore come BitsPerComponent * Colors * Columns

La difesa consiste nel rendere almeno un operando di tipo Int64, così che l'intera espressione venga valutata a 64 bit, per poi confrontare il risultato con MaxInt e rifiutare il file prima di riconvertire in basso e chiamare SetLength

PDF Library for Delphi: confronto fianco a fianco di un prodotto di dimensioni a 32 bit con wrap che causa un'allocazione sottodimensionata e una scrittura fuori dai limiti contro un controllo di allargamento Int64 che rifiuta il file
Valutare il prodotto del conteggio di byte in Int64 lascia al parser confrontare con MaxInt e respingere dimensioni ostili prima che SetLength intagli un buffer bugiardo
// Rifiuta prima di allocare, non dopo aver scritto.
// Valuta il prodotto in Int64 così che non possa andare in overflow a 32 bit.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // dimensioni ostili o non supportabili; rifiuta l'immagine
SetLength(Buffer, RowBytes * FHeight);

Ciò che rende questo scenario un problema tipico di Delphi è la riduzione silenziosa del tipo. L'assegnazione di un'espressione troppo ampia a una destinazione a 32 bit è una conversione valida che il compilatore non segnala per impostazione predefinita, e il controllo dell'intervallo (range checking) non rileva l'overflow che avviene prima dell'uso del valore come indice. Lasciando il calcolo a 32 bit, l'ambiente assegna silenziosamente una dimensione errata che non corrisponde alla memoria effettivamente interessata dalla decodifica

Un tipo di campo che impedisce l'attivazione del controllo

Un file TIFF è costituito da una catena di directory di file di immagini (IFD), ciascuna contenente l'offset del byte della successiva. Un file dannoso può indirizzare questa catena verso se stessa, e un lettore che la scorre senza una condizione di arresto continuerà a elaborare all'infinito. Questo corrisponde a CWE-835, un ciclo infinito gestito da input controllati dall'attaccante, e la difesa consiste in un contatore che arresta l'esecuzione quando supera un limite irraggiungibile per i file validi

Ora dichiara il contatore delle pagine come Word, che in Delphi memorizza valori da 0 a 65535, e dai al ciclo una guardia di terminazione del tipo "ferma quando il conteggio delle pagine supera 65535". Sembra corretta finché non noti che l'operando e la soglia condividono lo stesso limite superiore. Un Word non può mai essere superiore a 65535, quindi il confronto è strutturalmente sempre falso: quando il contatore raggiunge 65535, l'incremento successivo lo riporta a 0 per overflow, la guardia non vede mai un valore sopra la soglia e una catena IFD in ciclo mantiene il lettore in rotazione

La cura consiste nell'ampliare il tipo del campo in modo che la guardia possa esprimere un valore che il contatore può davvero memorizzare. TPDFTIFF.FPageCount è dichiarato come Integer, quindi il confronto FPageCount > 65535 è raggiungibile e il ciclo termina. Ogni volta che un controllo dei limiti ha la forma Value > MaxValueOfType(Value) e l'operando è già tipizzato esattamente a quel massimo, la condizione è una costante falsa: amplia il tipo, o verifica l'uguaglianza rispetto al massimo così che possa attivarsi

Controllo dell'intervallo disattivato su percorsi critici

Con il controllo dell'intervallo (range checking) attivo, Delphi inserisce una verifica dei limiti su ogni indice di array e stringa; questo fa la differenza tra un indice non valido che solleva un'eccezione intercettabile ERangeError e lo stesso indice che legge o scrive aree di memoria non appartenenti alla struttura. I percorsi critici (hot path) talvolta lo disattivano tramite la direttiva locale {$R-}, una scelta accettabile solo finché gli indici rimangono affidabili

L'accessorio dell'elenco su cui si basano gli interpreti dei font, ovvero TPDFlibStringList.Get, è esattamente un percorso di questo tipo. Su Windows viene compilato con il controllo dell'intervallo disattivato e indicizza direttamente la memoria di supporto, quindi un indice fuori intervallo sarebbe un accesso grezzo alla memoria anziché un errore. Va bene quando l'indice è sempre valido, e richiede attenzione dentro un interprete charstring CFF o Type2, dove l'indice può provenire dal file. Una charstring che estrae un operando da uno stack vuoto produce un indice pari a meno uno; un identificatore di glifo sfalsato di uno rispetto al conteggio dei glifi punta a uno slot oltre la fine. Lasciati senza controllo, entrambi sarebbero un reale accesso fuori dai limiti invece di un'eccezione gestibile e, poiché gli slot contengono valori AnsiString con conteggio dei riferimenti, una lettura errata può anche corrompere il contatore di riferimento della stringa

PDF Library for Delphi mantiene il controllo dell'intervallo disattivato su quel percorso critico e rende invece gli indici dimostrabilmente validi: prima di prelevare l'elemento superiore dello stack degli operandi, l'interprete verifica che lo stack non sia vuoto, e ogni controllo degli indici è una relazione stretta di minore rispetto al conteggio anziché un minore-o-uguale che ammette l'off-by-one. La direttiva sposta la responsabilità della gestione dei limiti dal compilatore a te, e la validazione che ha rimosso va rimessa a mano in ogni punto di ingresso

Ricorsione illimitata in un interprete charstring

Una charstring Type2 può chiamare una subroutine, la quale è a sua volta una charstring che può effettuarne altre, lasciando che la profondità di chiamata sia controllata dal file. Una subroutine che chiama se stessa, direttamente o tramite un ciclo, genera una ricorsione illimitata fino all'esaurimento dello stack nativo e al conseguente arresto del processo. Questo scenario corrisponde a CWE-674 (uncontrolled recursion)

L'interprete Type1 si protegge da questo con un contatore di profondità delle chiamate e un limite massimo, PLType1MaxCallDepth, e rifiuta di scendere oltre, in linea con il limite di profondità che la specifica Type1 stessa nomina. La trappola è che un interprete strutturalmente simile scritto più avanti, come quello Type2, non eredita da solo quella protezione, e un font creato ad hoc contenente una subroutine che richiama il proprio numero passa dritto attraverso il controllo mancante fino a un overflow dello stack

// La forma della guardia di profondità di cui hanno bisogno entrambi gli interpreti charstring.
// Traccia la profondità attraverso le chiamate annidate e rifiuta di ricorrere oltre.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // subroutine ostile autoreferenziale; interrompi la discesa
// ... interpreta la subroutine, poi Dec(CallDepth) in uscita

PDF Library for Delphi dà al percorso Type2 la stessa profondità limitata del suo gemello Type1. Qualsiasi discesa ricorsiva su struttura controllata dall'attaccante, siano subroutine di font, un array annidato o una catena di cross-reference, ha bisogno di un tetto di profondità che l'input non può sollevare

Memoria non inizializzata che trapela nell'output

La trappola più sottile fa trapelare contenuto dello heap nell'output decrittografato, e la causa è una proprietà di SetLength facile da dimenticare. Quando amplii un AnsiString con SetLength, Delphi alloca i byte ma non li azzera, quindi la nuova area mantiene quello che c'era prima in quella memoria heap. Se ogni byte viene successivamente scritto, non importa mai; se un percorso lascia parte del buffer non scritto e poi lo restituisce come dati, quei byte residui escono con il risultato. Quello è CWE-457, uso di memoria non inizializzata, e quando il risultato attraversa un confine di fiducia diventa una fuga di informazioni

Un percorso di decrittografia AES-CBC è esattamente dove questo morde. Dimensioni il buffer di output con SetLength, elabori il testo cifrato un blocco di 16 byte alla volta, e quando la lunghezza del testo cifrato non è un multiplo di 16, una lunghezza che un attaccante può scegliere, il blocco parziale finale non viene mai scritto, quindi quei byte finali mantengono il contenuto dello heap lasciato da SetLength e il buffer torna indietro come testo in chiaro decrittografato di un oggetto documento. Il rimedio è due guardie, e nessuna da sola basta: il punto di ingresso di decrittografia di PDF Library for Delphi rifiuta qualsiasi testo cifrato la cui lunghezza non sia multipla della dimensione del blocco, e come rete di sicurezza l'output viene pulito con FillChar prima dell'uso, così qualsiasi percorso che non scriva un'area restituisce zeri invece di residui dello heap

Diagramma PDF Library for Delphi della decifratura AES-CBC in cui un blocco finale non terminato lascia byte di heap non inizializzati a meno che i controlli della lunghezza della voce e la pulizia del buffer con FillChar non proteggano il percorso
Due guardie indipendenti chiudono la fuga: rifiutare il ciphertext la cui lunghezza manca il confine di 16 byte, e pre-azzerare l'output così anche una scrittura mancante produce zeri invece di residui di heap

Cosa hanno in comune le cinque trappole

Le cinque trappole sono bug diversi, ma fanno rima. Una larghezza di intero che fa fare l'overflow a un prodotto, un tipo di campo che inchioda una guardia a un falso costante, un controllo dell'intervallo disattivato dove gli indici avevano smesso di essere sicuri, una ricorsione senza pavimento, e un buffer che il linguaggio non azzera. In ognuna Delphi fa esattamente ciò che definisce, perché il linguaggio ti dà aritmetica che fa overflow, restringimenti silenziosi, controlli dell'intervallo disattivabili, ricorsione senza limite integrato e allocazione che non inizializza. Questo è il contratto, e un parser Pascal lo rispetta gestendo a mano quattro cose in ogni confine controllato dal file: larghezza degli interi, controllo dell'intervallo, profondità di ricorsione e inizializzazione dei buffer

PDF Library for Delphi, il motore per Delphi e C++Builder, mette in guardia il proprio percorso di decodifica contro tutte e cinque. Se il tuo lavoro riguarda anche le modalità con cui un file dichiara di essere protetto, le note correlate sulla verifica di crittografia e permessi e sull'analisi preliminare (preflight) PDF/A e PDF/UA trattano il lato di analisi dello stesso parser, e tutto ciò è incluso nella PDF Library for Delphi Delphi PDF Library insieme alle API di caricamento, rendering e firma descritte in altre sezioni di questo blog