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.
PDFlibPas è stato sottoposto a una fase di protezione che ha considerato ostile l'intero percorso di decodifica, compresi i programmi dei font (TrueType, Type1, CFF e 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 predittori Flate). Di seguito vengono presentate cinque classi di difetti corrette, ciascuna legata a comportamenti specifici di Delphi che ne consentivano la manifestazione. Queste vulnerabilità sono state risolte nelle versioni correnti, e i medesimi scenari si ripresentano in qualsiasi codice Pascal che esegua l'analisi di dati non attendibili.
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).
Il percorso condiviso delle immagini limitava già ciascuna dimensione a 65535; i decodificatori autonomi non includevano tutti questo controllo. Un'espressione che calcola i byte per riga per l'altezza come ByteCount * FHeight, o un calcolo per pixel come FWidth * Components * BitDepth, rappresenta un prodotto a 32 bit in Delphi se entrambi gli operandi sono interi a 32 bit, indipendentemente dalla larghezza della variabile a cui viene assegnato il risultato. Una larghezza e un'altezza pari a 60000 sono valori plausibili per scansioni grandi, ma il loro prodotto espresso in byte supera i limiti a 32 bit con segno, restituendo una dimensione ridotta. Lo stesso problema interessava lo stride del predittore ZLib, ovvero BitsPerComponent * Colors * Columns.
La soluzione consiste nel rendere almeno un operando di tipo Int64, garantendo che l'intera espressione venga valutata a 64 bit, per poi confrontare il risultato con MaxInt e rifiutare il file prima di eseguire la conversione e chiamare SetLength.
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
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.
Il contatore delle pagine era dichiarato come tipo Word, che in Delphi memorizza valori da 0 a 65535. Il ciclo conteneva un controllo di terminazione del tipo "ferma quando il conteggio delle pagine supera 65535", che appare corretto finché non si nota che l'operando e la soglia condividono lo stesso limite superiore. Un valore Word non può mai essere superiore a 65535, quindi il confronto strutturale è sempre falso: quando il contatore raggiunge 65535, l'incremento successivo lo riporta a 0 per overflow, il controllo non rileva mai un valore superiore alla soglia e la catena IFD circolare mantiene il lettore in esecuzione permanente.
La soluzione consiste nell'ampliare il tipo del campo in modo che il controllo possa valutare un valore memorizzabile dal contatore. Dichiarando TPDFTIFF.FPageCount come Integer, lo stesso confronto FPageCount > 65535 diventa raggiungibile, il ciclo termina e la proprietà pubblica PageCount ha modificato il proprio tipo per allinearsi senza compromettere i chiamanti. Quando un controllo dei limiti ha la forma Value > MaxValueOfType(Value) e l'operando corrisponde già a quel massimo, la condizione è sempre falsa: amplia il tipo o esegui una verifica di uguaglianza rispetto al valore massimo affinché 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, rientra in questa categoria. Su Windows viene compilato con il controllo dell'intervallo disattivato e indicizza direttamente la memoria di supporto, quindi un indice non valido non genera un errore ma un accesso diretto alla memoria. Questa gestione è corretta quando l'indice è valido, ma cessa di esserlo all'interno di un interprete charstring CFF o Type2, in cui l'indice proviene dal file. Una charstring che estrae un operando da uno stack vuoto restituisce 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. Con il controllo disattivato, entrambi generano 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ò corrompere il contatore di riferimento della stringa.
La protezione non ha riattivato il controllo dell'intervallo per il percorso critico. Ha invece reso validi gli indici preventivamente: prima di prelevare l'elemento superiore dello stack degli operandi, l'interprete verifica che lo stack non sia vuoto, e ogni controllo degli indici è stato definito con una relazione stretta di minore rispetto al conteggio totale, escludendo l'errore di tipo off-by-one. La direttiva sposta la responsabilità della gestione dei limiti dal compilatore allo sviluppatore, e il controllo rimosso deve essere implementato manualmente 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 integrava già una protezione per questo scenario. Conteneva un contatore di profondità delle chiamate e un limite massimo, PLType1MaxCallDepth, impedendo di superarlo, in linea con il limite specificato dallo standard Type1. L'interprete Type2, inserito successivamente e simile nella struttura, non includeva questa protezione, e un font creato ad hoc contenente una subroutine che chiama se stessa superava il controllo mancante causando un overflow dello stack.
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
La soluzione consiste nel fornire al percorso Type2 lo stesso limite di profondità già presente nella versione Type1. Qualsiasi ricorsione su strutture controllate dal file, come subroutine di font, array nidificati o catene di riferimenti incrociati, richiede un limite di profondità insuperabile dall'input.
Memoria non inizializzata che trapela nell'output
Il difetto più sottile esponeva porzioni dello heap nell'output decrittografato, causato da una caratteristica di SetLength che viene spesso dimenticata. Quando si amplia un tipo AnsiString tramite SetLength, Delphi alloca i byte ma non li azzera, quindi la nuova area mantiene i dati precedentemente presenti in quella memoria heap. Se ogni byte viene successivamente scritto, questo comportamento è ininfluente; se un percorso lascia una parte del buffer non scritta e la restituisce, quei byte residui faranno parte del risultato. Questo scenario corrisponde a CWE-457 (uso di memoria non inizializzata) e, quando il risultato supera un confine di sicurezza, si trasforma in una fuga di informazioni (leak).
Il percorso di decrittografia AES-CBC presentava proprio questo difetto. Il buffer di output veniva dimensionato tramite SetLength e il decrittografo elaborava il testo cifrato un blocco di 16 byte alla volta. Se la lunghezza del testo cifrato non era un multiplo di 16, valore configurabile da un utente malintenzionato, il blocco parziale finale non veniva scritto; pertanto, tali byte finali mantenevano il contenuto dello heap residuo lasciato da SetLength e il buffer veniva restituito come testo in chiaro decrittografato di un oggetto documento. La soluzione prevede due protezioni, ed entrambe sono necessarie: il punto di ingresso di decrittografia ora rifiuta qualsiasi testo cifrato la cui lunghezza non sia multipla della dimensione del blocco, e come sicurezza aggiuntiva l'output viene pulito con FillChar prima dell'uso, in modo che qualsiasi percorso che non scriva un'area restituisca zeri invece di residui dello heap.
Le conclusioni di questa fase di ottimizzazione
I cinque difetti sono anomalie diverse, ma collegate. Una larghezza di intero che causa l'overflow di un prodotto, un tipo di campo che rende un controllo costantemente falso, una verifica dell'intervallo disattivata dove gli indici non erano sicuri, una ricorsione senza limiti e un buffer non inizializzato dal linguaggio. In ognuno di questi scenari Delphi si è comportato in linea con le proprie specifiche, poiché il linguaggio prevede calcoli soggetti a overflow, riduzioni silenziose del tipo, controlli disattivabili, ricorsioni prive di limiti integrati e allocazioni prive di azzeramento automatico. Questo è il comportamento del linguaggio, e un parser Pascal lo gestisce controllando manualmente quattro elementi in ogni punto controllato dal file: larghezza degli interi, verifica degli intervalli, profondità di ricorsione e inizializzazione dei buffer.
Questi difetti sono risolti nelle versioni correnti di PDFlibPas, il motore per Delphi e C++Builder. 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 l'intera suite è inclusa in PDFlibPas Delphi PDF Library insieme alle API di caricamento, rendering e firma descritte in altre sezioni di questo blog.