PDF Library for Delphi ha trovato cinque difetti nei decoder mentre portava il codice CCITT, TIFF, PNG, Flate e stream buffer su Free Pascal, e ognuno di essi passava l'intera suite di test Delphi da anni. Nessuno era un bug del compilatore. Ognuno era Pascal che Delphi eseguiva correttamente per caso, grazie a un dettaglio implementativo: un parametro result nascosto che aliasava l'array del chiamante, un ramo fuori intervallo oltre il quale nessuno ha mai letto, un buffer di lunghezza zero la cui unica protezione era uno switch di range checking, un offset 1-based che un solo percorso passava come 1, e un contratto di TStream.Read che gli stream in memoria non esercitano mai. Cambia compilatore, o dai allo stesso codice un file malformato, e l'incidente smette di reggere
Quello che segue è la forma specifica di ognuno, la correzione e la disciplina che ne è nata: ora lo stesso sorgente deve produrre la stessa semantica di documento su entrambi i compilatori, e un include di test verifica che lo faccia. L'articolo gemello su come rendere robusto un parser PDF Pascal contro file malevoli copriva la larghezza degli interi, la profondità di ricorsione e i buffer non inizializzati. Questo riguarda una classe di guasto diversa: codice sbagliato fin dall'inizio che aveva un compilatore a coprirgli le spalle in silenzio
Perché una funzione che ritorna un array dinamico funziona senza SetLength in Delphi?
Perché Delphi passa la variabile del chiamante come parametro result nascosto, quindi una funzione che non alloca mai il suo risultato può comunque scrivere in un array che ha allocato il chiamante. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray è la ricerca sulla linea di riferimento al cuore della decodifica bidimensionale Group 3 e Group 4: data la posizione corrente a0 e il colore del run corrente, cerca gli elementi di cambiamento della scanline precedente, i b1 e b2 dello schema di codifica bidimensionale ITU-T T.4 e T.6, e li restituisce come array di due slot. La funzione originale scriveva Result[0] e Result[1] e non chiamava mai SetLength su Result
Questo dovrebbe andare in fault alla prima scrittura, e su Free Pascal succede. Su Delphi non è mai accaduto, perché entrambi i call site nel decoder sono fatti così: dichiarano b: TCCITTIntegerArray, eseguono SetLength(b, 2) una volta prima del ciclo sulle scanline, poi dentro il ciclo assegnano b := GetNextChangingElement(a0, IsWhite) e leggono b[0] e b[1]. La guida al linguaggio Delphi afferma che una funzione il cui risultato è una long string, un array dinamico o un altro tipo managed riceve quel risultato come parametro var aggiuntivo, e in pratica il compilatore passa l'indirizzo della destinazione dell'assegnazione. Quindi Result dentro la funzione è b stesso, già lungo due elementi, e ogni scrittura finisce in memoria di proprietà del chiamante. Free Pascal passa alla funzione un array nil nuovo e lo assegna a b dopo, che è la lettura del contratto su cui il codice avrebbe dovuto essere scritto fin dall'inizio
L'aliasing portava con sé anche una semantica da cui il decoder dipende. Result[0] viene assegnato solo quando la scansione trova un elemento maggiore di a0, e Result[1] solo quando c'è un elemento dopo di esso, quindi in caso di miss gli slot conservano quello che l'iterazione precedente aveva lasciato in b. La correzione ovvia, allocare due slot e azzerarli a ogni chiamata, avrebbe distrutto quel carry-over e cambiato l'output decodificato su Delphi. La correzione che è stata rilasciata è invece una guardia, non un reset: su Delphi è codice morto e il percorso di decodifica resta byte per byte quello di prima, su Free Pascal trasforma un fault nel comportamento voluto. Questa asimmetria è tutto il punto, visto che la correzione doveva essere un no-op sul compilatore dove il codice produceva già output verificato
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi arriva qui con l'array di due elementi del chiamante aliasato
// come Result, quindi qui è un no-op. FPC arriva con nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] vengono ancora scritti solo su un hit, quindi un miss
// conserva i valori dell'iterazione precedente esattamente come prima
End;
Un contatore che sopravvive ai suoi dati: la directory entry TIFF
Quando invalidi un array devi invalidarne il contatore nella stessa istruzione, altrimenti quel contatore verrà creduto da codice che l'array non lo vede mai. Una image file directory entry TIFF (TIFF 6.0 §2, la struttura da 12 byte con tag, type, count e value-or-offset) porta con sé un count a 32 bit letto direttamente dal file, e PDF Library for Delphi legge ognuna tramite PopDE: TTIFFEntry, un record con Tag, TagType, Length, Offset e gli array decodificati IntegerValues e DoubleValues. Il codice originale controllava se Offset + TypeSize * Length andava oltre la fine del file e, in quel caso, azzerava la lunghezza di entrambi gli array. Lasciando però Result.Length al valore letto dal file
Da lì sono andate storte due cose. La funzione termina con un fallback che dice «se Length è zero, dai alla entry un elemento a valore zero», così i chiamanti possono sempre leggere l'elemento zero. Dato che Length non veniva mai azzerato sul percorso fuori intervallo, quel fallback non scattava mai per l'unico caso per cui esisteva. E i chiamanti l'elemento zero lo leggono, incondizionatamente: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip e una dozzina d'altri prendono E.IntegerValues[0], e le tabelle degli strip fanno Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), copiando Length volte quattro byte da un array che non ne ha nessuno. Un array azzerato con un contatore ancora vivo è decisamente più pericoloso di uno non controllato, perché quello non controllato almeno contiene i byte che dichiara
Il secondo problema era l'ordine. Le due chiamate a SetLength giravano prima del test di intervallo, dimensionate sul count del file, quindi una entry ostile poteva chiedere un'allocazione da diversi gigabyte prima di un singolo controllo di validità. Su Delphi l'eccezione risultante finiva in un handler più in alto nel percorso di caricamento dell'immagine e il file semplicemente non si caricava, ed è per questo che nessuno se ne accorgeva; quello che succedeva davvero era un evento di out-of-memory scelto dal file. La correzione sposta l'allocazione dopo il test e fa viaggiare il count insieme ai dati
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // il count va via insieme ai valori
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // solo ora
SetLength(Result.DoubleValues, Result.Length);
End;
// ... più avanti, il fallback esistente arriva finalmente al caso per cui esisteva:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Questa correzione non ha nulla di specifico per un compilatore, ed è proprio questo che la fa stare in questa lista. Il difetto era latente su Delphi per la stessa ragione per cui era latente su Free Pascal: nessun file di test aveva una directory entry che puntava oltre la fine del file. Il porting non lo ha esposto. Lo ha fatto la lettura del codice con la domanda «cosa fa Delphi per me qui che non sto già facendo da solo»
Cosa succede quando un IHDR PNG dichiara un color type che il formato non definisce?
PDF Library for Delphi ora rifiuta l'immagine prima che girino i row filter; prima della v3.539.2 calcolava una scanline da zero byte e passava ai cicli di unfilter un buffer vuoto. ISO 15948 §11.2.2 definisce il chunk IHDR e la Table 11.1 elenca le sei combinazioni legali di color type e bit depth: grayscale a 1, 2, 4, 8 o 16 bit, indexed color a 1, 2, 4 o 8, e truecolor, grayscale con alpha e truecolor con alpha a 8 o 16. TPNGReader validava i campi compression method e filter method di IHDR e lasciava passare FColorType e il bit depth intatti
Il codice dei row filter dimensiona tutto a partire da un Case FColorType Of che mappa ogni color type su un numero di componenti. Un color type fuori dai sei finisce nel ramo Else, dove SourceComponents è 0, quindi ScanlineByteCount è 0, quindi a SetLength(PreviousScanline, 0) segue immediatamente FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indicizzare l'elemento zero di un array dinamico vuoto è un indirizzo calcolato da nil. Con il range checking disattivato, un fill da zero byte attraverso quell'indirizzo è un no-op silenzioso e il decoder prosegue su righe che non esistono; con il range checking attivo è un ERangeError alla prima immagine; e le chiamate a Move che seguono sono a un passo da una access violation. Cosa ti capita dipende dal compilatore e dagli switch di build, non da qualcosa che il decoder abbia deciso, e questo è il segnale che il decoder non aveva deciso proprio nulla
La correzione è la tabella della specifica, applicata dove gli altri campi di IHDR erano già controllati: COLOR_GRAYSCALE accetta FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE accetta [1, 2, 4, 8], e COLOR_RGB, COLOR_GRAYSCALEALPHA e COLOR_RGBALPHA accettano [8, 16]; qualsiasi altro valore azzera ValidImage e l'immagine viene rifiutata con larghezza e altezza intatte per la diagnostica. Un chunk pHYs più corto dei suoi nove byte è stato chiuso nello stesso passaggio, dato che il lettore DPI indicizzava S[1] fino a S[8] di una stringa che il chunk corto aveva lasciato vuota
Un offset 1-based trattato come puntatore 0-based
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString prende uno StartPos 1-based, perché il suo input è un AnsiString e l'implementazione Delphi indirizza l'input zlib come @Input[StartPos]. L'implementazione Free Pascal, scritta contro paszlib perché entrambi i target Windows linkino la compressione staticamente, impostava next_in a PAnsiChar(Input) + StartPos e avail_in a Length(Input) - StartPos. Questa è aritmetica sui puntatori, ed è 0-based. Passa 1, che è quello che «parti dall'inizio» significa per questa funzione, e la build FPC inizia a sgonfiare dal secondo byte e si ferma un byte prima della fine
La ragione per cui è sopravvissuto è che l'unico chiamante che quasi tutti i test raggiungono è InflateStr, che passa 0. Zero è per l'appunto l'offset 0-based corretto, quindi le due build concordavano su ogni chiamata semplice a InflateStr e su ogni test che ci passava attraverso. TPDFDocument.DecodeAllStreams, la routine che SaveQDFToFile e ConvertFileToQDF usano per espandere gli stream con solo FlateDecode in forma leggibile, passa 1. Sulla build FPC l'header zlib saltato faceva fallire l'inflate, ma lo stream zlib riportava comunque un Consumed diverso da zero per i byte che aveva esaminato, quindi DecodeAllStreams prendeva il payload vuoto per una decodifica riuscita e sostituiva ogni content stream con una stringa vuota. Il QDF risultante aveva il numero di pagine giusto, struttura valida e nessun contenuto di pagina, cioè un file che si apre senza errori in ogni viewer e non mostra niente
// Ramo FPC di InflateStrFromPosition, dopo la v3.539.16.
// StartPos è 1-based come nel ramo Delphi; la si limita, poi la si converte
// in un offset di puntatore 0-based esattamente una volta, al confine.
If (StartPos < 1) Then
StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
Exit;
...
strm.next_in := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;
Il test di regressione che lo protegge è il più piccolo possibile: comprimi un payload, lo espandi dalla posizione 0 e dalla posizione 1, e verifichi che entrambi restituiscano lo stesso payload e riportino Consumed uguale alla lunghezza intera dello stream. Uno stream RFC 1950 ha un header di due byte e un trailer Adler-32 di quattro, quindi un off-by-one su una delle due estremità non è una corruzione sottile, è uno stream che o non parte o non finisce. La lezione riguarda il confine, non zlib: quando il parametro di una funzione è definito in una base di indici e l'implementazione sotto usa l'altra, la conversione va in una sola riga, e un test deve chiamarla con il valore che distingue le due basi
Perché un TStream.Read corto non è la fine dello stream?
Perché TStream.Read può restituire meno byte di quelli richiesti per qualunque motivo gli piaccia, e solo un ritorno di 0 significa che non c'è più niente. TMemoryStream e TFileStream su disco locale riempiono quasi sempre la richiesta, ed è per questo che il codice che tratta «ha restituito meno di quanto avevo chiesto» come end-of-file passa ogni test che li usa. Stream su rete, stream di decompressione e qualunque discendente di TStream scritto da un cliente possono restituire due byte quando ne chiedi sessantaquattromila e avere comunque gigabyte dietro
TPLBuffer è il lettore attraverso cui passa ogni parser di PDF Library for Delphi, e può incapsulare un AnsiString, un puntatore, un array di byte o un TStream. Le sue quattro query di scansione, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte e DistanceToOtherBytes, tutte con ritorno Int64, leggono la sorgente in blocchi da 64 KB cercando un delimitatore e riportano quanto è lontano senza muovere la posizione logica. Ogni ciclo terminava con Until ReadCount < BlockSize. Per le tre sorgenti in memoria è corretto, dato che ReadIntoBuffer consegna sempre il blocco intero fino all'ultimo. Per la sorgente stream significa che la scansione si arrende al primo short read, riporta il delimitatore come assente, e il tokenizer sopra di essa decide che l'oggetto finisce dove non finisce
// TPLBuffer.DistanceToByte, il ciclo dopo la v3.539.6.
// Zero è l'unico segnale di fine dati che TStream.Read definisca.
TempPosition := FPosition;
Try
Repeat
ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
For TestPos := 0 To ReadCount - 1 Do
If TempBuffer[TestPos] = Value Then
Begin
Result := TotalSkipped + TestPos;
Exit;
End;
Inc(TotalSkipped, ReadCount);
Until ReadCount = 0;
Finally
FPosition := TempPosition; // una peek non deve muovere il lettore
End;
Il test che fissa questo comportamento è un discendente di TMemoryStream il cui override di Read limita ogni richiesta a due byte. Ci avvolgi la stringa aaaaaX, porti la posizione del buffer a 1, e tutte e quattro le query devono riportare una distanza di 4 dalla X, lasciare la posizione a 1 dopo la chiamata e riportare -1 per un byte che non c'è. Prima della correzione la prima query vedeva due byte, concludeva che lo stream era esaurito e restituiva -1. Il finally conta quanto la condizione del ciclo: un Exit dall'interno della scansione è il normale percorso di successo, e la posizione logica va ripristinata anche su quel percorso, non solo quando il ciclo arriva in fondo
Un sorgente, due compilatori, un solo set di asserzioni
La disciplina che è uscita da questi cinque casi è che «la build Delphi passa» è una prova su Delphi, non sul sorgente. Dalla v3.539.16 la suite Delphi DUnitX e la suite console Free Pascal includono entrambe lo stesso Tests\CrossCompilerSemantics.inc, una singola routine, RunCrossCompilerFileSemantics, che costruisce un documento di due pagine con contenuto compresso tramite TPDFlib, lo salva, lo salva di nuovo come QDF con SaveQDFToFile, ripara il QDF con RepairQDFFile, cifra il file in chiaro con AES-128 tramite EncryptFile e una maschera di permessi da EncodePermissions, e poi ricarica ogni artefatto e verifica le stesse cose su entrambi i compilatori: il numero di pagine è 2, il titolo sopravvive, il testo della seconda pagina si estrae intatto dai file in chiaro, riparato e cifrato, la password sbagliata viene rifiutata con un LastErrorCode diverso da zero, EncryptionStrength è 128, EncryptionAlgorithm è 2, e i singoli bit di permesso da GetUserPermissions tornano esattamente come codificati
Il confronto è volutamente normalizzato invece che byte per byte. La cifratura pesca salt casuali e il writer assegna identificatori di documento, quindi non ci si aspetta che le due build emettano file identici; ci si aspetta che emettano file che significano la stessa cosa, e le asserzioni sono formulate a quel livello. Il ramo QDF c'è proprio per il bug dell'offset: un QDF con due pagine e nessun contenuto passa un controllo sul numero di pagine e fallisce un controllo di estrazione del testo, e la matrice verifica il secondo. Qualunque correzione futura che sia un no-op su un compilatore e un cambio di comportamento sull'altro, il che descrive quattro dei cinque casi qui sopra, ora deve superare due volte le stesse asserzioni prima di essere rilasciata
La metà di questo porting che riguarda il link time, far accordare gli oggetti OMF di Delphi con le aspettative COFF di Free Pascal, è una storia a sé in il linking di oggetti FPC Win32 da OMF a COFF, e l'hardening strutturale dello stesso lettore TIFF contro BigTIFF e file tiled è in le note sul decoder TIFF integrato. I decoder di questo articolo, e il test cross-compiler che ora gli sta sotto, sono distribuiti in PDF Library for Delphi per Delphi, C++Builder e Free Pascal, dove ci si aspetta che lo stesso sorgente si guadagni lo stesso risultato su ogni compilatore che ha come target, invece di riceverlo in regalo da uno solo