Quando una tabella di cross-reference PDF è inutilizzabile, il rimedio è ignorarla del tutto e ricostruirla a partire dal corpo del file. PDFlibPas Delphi PDF Library lo fa con uno scanner a token a passata singola che registra ogni autentica intestazione di oggetto indiretto che incontra, poi recupera il dizionario trailer e passa la tabella ricostruita al normale loader
Cosa si rompe per primo quando un PDF è danneggiato
La tabella di cross-reference è la parte più fragile di un PDF, perché è l'unica parte che memorizza offset di byte assoluti. ISO 32000-1 §7.5.4 definisce quelle voci come offset a dieci cifre dall'inizio del file, e §7.5.5 colloca la keyword startxref verso la fine puntando alla tabella stessa. Ognuno di quei numeri viene invalidato da qualunque modifica che sposti i byte. Una sessione FTP eseguita in modalità testo che ha tradotto i CRLF, un download troncato, un settore diventato difettoso su un'unità condivisa, uno strumento batch che ha accodato dati senza scrivere correttamente un aggiornamento incrementale: tutti lasciano i dati degli oggetti perfettamente leggibili e l'indice che punta a spazzatura
Ecco perché "il file è danneggiato e viene riparato" è un messaggio così comune. I byte quasi sempre sono ancora lì. Ciò che manca è la mappa. La ricostruzione non è quindi un recupero forense di dati perduti, è la ricostruzione di un indice derivabile dal corpo del file, e ha successo molto più spesso di quanto gli utenti si aspettino perché il contenuto costoso, alberi di pagine, font e immagini, resta intatto
Perché la scansione di N 0 obj trova corrispondenze false?
Una ricostruzione ingenua cerca nei byte grezzi il pattern "intero, intero, obj" e registra ogni corrispondenza. Ne trova troppe. PDF è un formato contenitore, e tre regioni di un file sono opache alla grammatica degli oggetti: commenti (§7.2), stringhe (§7.3.4) e dati di stream (§7.3.8). Ognuna di esse può contenere byte che si leggono esattamente come un'intestazione di oggetto, e nessuna di esse è un'intestazione di oggetto. Una didascalia in una stringa letterale, un commento di debug dimenticato, o due megabyte di output Flate o DCT produrranno tutti tranquillamente qualcosa che sembra 99 0 obj
const
Trap: AnsiString =
'4 0 obj'#10 +
'(a caption that mentions 88 0 obj)'#10 + // literal string, not an object
'endobj'#10 +
'% 77 0 obj left over from a debug dump'#10 + // comment, not an object
'5 0 obj'#10 +
'<< /Length 2097152 >>'#10 +
'stream'#10 +
{ two MiB of compressed bytes that contain the byte sequence
99 0 obj and, further along, a complete endstream }
'endstream'#10 +
'endobj'#10;
Ogni voce falsa costa doppio. Inquina la tabella ricostruita con un numero di oggetto che non esiste, e può oscurare un oggetto reale con lo stesso numero che compare più avanti nel file. PDFlibPas quindi non fa pattern-matching. Tokenizza, il che significa che sa sempre se i byte sotto il cursore sono codice o payload, e il payload viene saltato senza mai essere interpretato
Una macchina a stati a passata singola su blocchi di 64 KiB
PDFlibPas scansiona l'intero file esattamente una volta, in blocchi da 64 KiB, con una macchina a stati costruita sulle regole dei token di ISO 32000-1 §7.2 e sulla sintassi degli oggetti indiretti di §7.3.10. Un token termina a uno spazio bianco o a uno dei caratteri delimitatori, e un'intestazione di oggetto viene registrata solo quando è stata vista una sequenza completa di un numero di oggetto positivo, un numero di generazione non negativo, e una keyword obj nuda. L'offset registrato è l'inizio del token del numero di oggetto, che è ciò a cui una voce di cross-reference deve puntare, non la posizione della keyword obj
function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
Result := (Value = 0) or (Value = 9) or (Value = 10) or
(Value = 12) or (Value = 13) or (Value = 32);
end;
function RebuildIsDelimiter(Value: Byte): Boolean;
begin
Result := (Value = Ord('(')) or (Value = Ord(')')) or
(Value = Ord('<')) or (Value = Ord('>')) or
(Value = Ord('[')) or (Value = Ord(']')) or
(Value = Ord('{')) or (Value = Ord('}')) or
(Value = Ord('/')) or (Value = Ord('%'));
end;
Il dettaglio importante è che lo stato del token e lo stato della stringa sopravvivono a un confine di blocco. Un'intestazione che attraversa la linea dei 65536 byte viene comunque riconosciuta, perché il token parziale, la coppia di interi in sospeso e i flag di stato-stringa vengono tutti portati nel blocco successivo. I buffer sono fissi: 64 KiB per la scansione, 32 byte per il token più lungo che possa mai contare, e gli unici array che crescono con il file sono le liste di numero di oggetto, numero di generazione e offset a 64 bit, che sono proporzionali al numero reale di oggetti anziché alla dimensione del file. In pratica la scansione emette letture sequenziali e al massimo due seek espliciti sull'intero documento, il che la rende praticabile sugli input di centinaia di megabyte trattati in l'articolo su merge e split ad accesso diretto
Perché non ci si può fidare che uno stream finisca a endstream?
Perché i dati di uno stream sono byte arbitrari, e byte arbitrari possono formare per caso endstream. Uno stream che inizia dopo la keyword stream deve essere saltato come dato opaco finché non termina davvero, ma la prima occorrenza della keyword di chiusura è solo un candidato. PDFlibPas risolve questo richiedendo una conferma: un token endstream viene accettato come la fine reale dello stream solo quando il token successivo non-spazio è un endobj autonomo, la sequenza richiesta da §7.3.8 attorno a un oggetto stream. Una corrispondenza casuale dentro dati compressi quasi mai ha quel seguito, quindi lo scanner resta dentro lo stream e continua. Due regole minori contano altrettanto. La keyword stream entra nello stato stream solo quando è una keyword nuda, quindi un oggetto nome come /stream in un dizionario non lo attiva mai. E un token obj o trailer viene onorato solo quando il token non ha superato il limite di 32 byte e non è iniziato con una barra. Senza queste due protezioni un dizionario di risorse con i nomi di chiave sbagliati basterebbe a far deragliare la scansione, che è esattamente la classe di input avversario trattata nelle note su parsing sicuro di PDF non attendibili
Trovare la fine reale del dizionario trailer
Recuperare gli oggetti è solo metà del lavoro, perché il loader ha comunque bisogno di un trailer per trovare /Root. PDFlibPas ricorda le ultime 64 posizioni della keyword trailer trovate durante la scansione e le valida a ritroso, dalla più recente, così il trailer utilizzabile più nuovo vince e una keyword isolata non seguita da un dizionario semplicemente fallisce la validazione e passa al candidato precedente. Ogni candidato viene letto con un limite di 1 MiB, e la fine del dizionario viene individuata tracciando la profondità annidata di << e >> insieme agli escape delle stringhe letterali, alle stringhe esadecimali e ai commenti
// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
' /Custom << /Text (value >> preserved) >> >>'#10
Il tracciamento della profondità non è accademico. Un trailer troncato che perde /Encrypt trasforma un documento crittografato recuperabile in uno inapribile, e perdere /Info o un sotto-dizionario personalizzato scarta silenziosamente metadati da cui un sistema a valle potrebbe dipendere. Se il file è crittografato, il trailer recuperato è ciò che consente l'esecuzione del normale percorso di credenziali, e la semantica di retry è la stessa descritta in l'articolo sul caricamento di documenti crittografati
Cosa la ricostruzione non può restituire
La ricostruzione è un tentativo migliore possibile, ed essere onesti sui suoi limiti fa parte del rilasciarla. Tre casi falliscono del tutto. Gli oggetti impacchettati dentro object stream (§7.5.7) non sono singolarmente visibili a una scansione di byte, quindi se un contenitore sopravvive ma il suo stream di cross-reference (§7.5.8) no, gli oggetti che contiene non vengono indicizzati dalla ricostruzione. Un file il cui corpo è stato effettivamente corrotto, anziché semplicemente disindicizzato, produrrà intestazioni il cui contenuto non si analizza più correttamente. E un file senza alcuna keyword trailer recuperabile e senza un catalogo leggibile non ha nulla a cui ancorare un albero di documento, indipendentemente da quante intestazioni di oggetto siano state trovate
I numeri di oggetto duplicati sono l'interessante caso intermedio. Un file aggiornato in modo incrementale contiene legittimamente diverse generazioni dello stesso numero di oggetto, e la catena di cross-reference sopravvissuta è l'unico registro di quale fosse quella corrente. Una ricostruzione non ha quella catena, quindi registra ogni intestazione che vede nell'ordine del file e risolve per numero di oggetto in seguito. Di solito vince la revisione più recente, il che di solito è corretto, ma un documento che è stato aggiornato e poi parzialmente ripristinato può tornare sottilmente diverso da quanto descriveva l'xref originale. I file linearizzati portano la stessa avvertenza dalla direzione opposta: il layout della prima pagina e le tabelle hint diventano insignificanti una volta rigenerato l'indice, quindi un file riparato dovrebbe essere trattato come un documento semplice, non linearizzato
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
begin
if Pdf.GetDocumentRepaired = 1 then
LogWarning('xref was unusable; the table was reconstructed');
if Pdf.PageCount > 0 then
Pdf.SaveToFile('recovered-invoice.pdf'); // writes a clean xref
end;
finally
Pdf.Free;
end;
end;
Il fallback è automatico: PDFlibPas esegue la scansione grezza ogni volta che la catena di cross-reference non può essere letta, e anche quando ogni voce in uso dichiara offset zero, che è la firma di una tabella scritta ma mai riempita. GetDocumentRepaired restituisce 1 quando quel percorso è stato eseguito, ed è bene registrarlo anziché ignorarlo, perché un documento caricato tramite ricostruzione dovrebbe essere risalvato in un file pulito anziché lasciato in una pipeline come se nulla fosse successo. Salvarlo scrive una tabella di cross-reference nuova e coerente, che è la correzione più economica possibile per ogni consumer a valle
Il percorso di ricostruzione, il flag GetDocumentRepaired e il loader in streaming mostrati qui fanno parte di PDFlibPas Delphi PDF Library, insieme alle API di parsing, rendering e firma trattate altrove su questo blog