Technical Article

Protezione di un binding PDFium VCL: ABI e sicurezza della memoria

Un binding Pascal su una libreria C si presenta come normale codice Pascal. Si chiama un metodo, si riceve un record e si libera la memoria allocata. Il problema è che PDFium è una libreria C e C++ con la propria convenzione di chiamata, le proprie larghezze degli interi e le proprie regole sulla gestione e il rilascio della memoria. Nulla di tutto ciò supera da solo il confine tra i linguaggi. Ognuno di questi contratti deve essere ridefinito manualmente nelle dichiarazioni Pascal, e una singola parola errata trasforma una chiamata apparentemente corretta in una corruzione dello stack, un offset troncato o una doppia liberazione (double free). Un controllo eseguito sulla versione 1.61.0 del binding PDFium VCL ha rilevato un difetto per ciascun tipo. Vale la pena esaminarli perché non sono specifici di questo binding. Rappresentano i rischi costanti dell'integrazione di qualsiasi API C in Delphi o Lazarus.

cdecl fa parte del tipo di funzione, non è una decorazione

PDFium è compilato in C. Su Win32 le sue funzioni esportate e, soprattutto, le callback da esso richiamate utilizzano la convenzione di chiamata cdecl. Con cdecl, il chiamante pulisce lo stack dopo il ritorno della chiamata. L'impostazione nativa predefinita di Delphi è register, e lo standard C Win32 per le callback è stdcall in alcune librerie, in cui è invece il chiamato a pulire lo stack. Quando una struttura passa a PDFium un puntatore a funzione e si omette la convenzione cdecl sul tipo di tale puntatore, le due parti non concordano su chi debba regolare il puntatore dello stack. Entrambi lo modificano, o nessuno dei due lo fa, e il puntatore dello stack devia della dimensione degli argomenti a ogni chiamata.

Il motivo per cui questo difetto è difficile da individuare è che il danno non si manifesta a livello locale. La chiamata corrotta viene completata e sembra corretta. Il disallineamento si presenta in seguito, in una funzione non correlata il cui frame risiede ora su un puntatore dello stack sfalsato di pochi byte, e si manifesta come una lettura casuale, un indirizzo di ritorno errato o un crash con un tracciato di chiamate che non indica la callback errata. La compilazione dei moduli è l'area tipica in cui si presenta questo problema, poiché l'interfaccia di compilazione è un record ricco di callback richiamate da PDFium. Una di queste, FFI_OpenFile, passa a PDFium una funzione che verrà chiamata per aprire un file esterno, dichiarata come function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Il termine finale cdecl è l'elemento essenziale da inserire. Rimuovendolo, il codice viene comunque compilato, collegato ed eseguito correttamente fino al momento in cui PDFium chiama la funzione. La convenzione appartiene al tipo stesso di funzione. Non è un elemento opzionale, e il compilatore non segnalerà la sua mancanza poiché un tipo di funzione semplice è perfettamente valido in Pascal. L'unica difesa consiste nel considerare la convenzione di chiamata come un campo obbligatorio per ogni firma importata e per ogni callback passata all'esterno.

size_t corrisponde alla larghezza del puntatore, e su FPC Win64 equivale a 64 bit

Il secondo difetto è una discrepanza nella larghezza dell'intero che si presenta solo su una specifica piattaforma. Il tipo size_t del C è definito in modo da essere sufficientemente ampio da contenere la dimensione di qualsiasi oggetto, il che su una piattaforma a 64 bit si traduce in un intero a 64 bit senza segno. Le interfacce di caricamento progressivo di PDFium utilizzano offset di byte di tipo size_t. Il record FX_FILEAVAIL del provider di disponibilità contiene una callback IsDataAvail callback che PDFium chiama passando un offset e una dimensione, e la callback AddSegment del record FX_DOWNLOADHINTS riceve gli stessi dati. Entrambi i parametri sono di tipo size_t.

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Se si dichiarano questi offset come tipi a 32 bit, il binding funziona su Win32 e su Delphi Win64, ma fallisce in modo silenzioso su FPC e Lazarus Win64. La causa è sottile. Su FPC Win64, NativeUInt è un tipo a 64 bit corrispondente alla larghezza del puntatore, e size_t è associato ad esso. Nel binding è presente un commento nella sezione dei tipi che avverte proprio di non nascondere NativeUInt su FPC, poiché ridefinirlo con un alias a 32 bit costringerebbe size_t a 32 bit, corrompendo ogni parametro size_t passato o scritto dalla libreria. Un offset a 64 bit che giunge a un parametro a 32 bit perde la sua metà superiore. Per file di piccole dimensioni ogni offset rientra nei 32 bit e non si verificano problemi. Per file di grandi dimensioni, quando un offset supera la soglia dei quattro gigabyte, il valore troncato punta a una posizione errata, PDFium richiede se l'intervallo di byte errato sia disponibile, e il caricamento progressivo si blocca o legge dati errati. Il difetto rimane invisibile finché il file non è abbastanza grande e la piattaforma è quella in cui size_t si è effettivamente ampliato.

Un'eccezione Pascal non deve mai propagarsi attraverso un frame C

La terza classe riguarda il modello delle eccezioni, assente in C. Quando PDFium chiama una delle tue callback, il codice Pascal viene eseguito in uno stack di frame C e C++ che non dispongono del sistema di gestione delle eccezioni di Delphi. Se la callback solleva un'eccezione e la lascia propagare, questa risale attraverso frame che non sono predisposti a questa operazione. Le operazioni di pulizia interne di PDFium non vengono eseguite, le sue variabili interne rimangono parzialmente aggiornate e il processo si trova in uno stato non previsto dalla libreria. Il contratto per queste callback prevede un codice di ritorno, non un'eccezione.

Due callback rendono concreta questa situazione. FPDF_FILEWRITE è il canale in cui PDFium scrive un documento salvato, e FPDF_FILEACCESS è la sorgente da cui legge un documento in ingresso. Entrambi sono implementati qui su un oggetto TStream di Delphi, ed entrambi possono fallire per le ragioni tipiche degli stream: il disco è pieno, lo stream viene chiuso inaspettatamente o una lettura supera il limite finale. La callback di scrittura racchiude la scrittura dello stream e converte qualsiasi errore nel codice di errore di PDFium anziché lasciarlo propagare.

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

La parte di lettura si comporta allo stesso modo: una lettura non riuscita restituisce zero per rispettare il contratto di FPDF_FILEACCESS anziché sollevare un'eccezione oltre il confine. Un blocco except vuoto senza un rilancio dell'eccezione appare errato a un programmatore Pascal abituato a non ignorare mai gli errori, e nel normale codice Pascal lo è. Presso un confine ABI rappresenta la struttura corretta, poiché l'unico valore sicuro da restituire al chiamante C è un codice di stato che questo sia in grado di interpretare. L'errore viene comunque propagato tramite il valore di ritorno, e il codice chiamante a monte della libreria lo presenterà come EPdfError non appena il controllo tornerà al codice Pascal.

La doppia liberazione (double free) si nasconde nel percorso di errore

Il quarto difetto riguarda la proprietà (ownership). Un handle di documento PDFium viene aperto dalla libreria e deve essere chiuso esattamente una volta tramite FPDF_CloseDocument. Il pericolo risiede in un percorso di errore che rilascia un handle già controllato da una seconda operazione di pulizia. Considera una routine che crea un oggetto wrapper, gli assegna un handle di documento appena aperto e quindi esegue altre configurazioni che potrebbero fallire. Se la configurazione fallisce sollevando un'eccezione, un gestore a ritorno anticipato che chiama FPDF_CloseDocument sull'handle grezzo lo chiuderà, e successivamente il distruttore dell'oggetto wrapper lo chiuderà nuovamente al rilascio dell'oggetto. L'handle viene così liberato due volte, un comportamento non definito che causa solitamente un arresto anomalo.

Il controllo ha rilevato questo problema su un percorso di importazione per l'imposizione che crea un oggetto TPdf attorno a un handle già aperto. La soluzione consiste nel rendere il trasferimento della proprietà l'unica fonte di verità. Una volta assegnato l'handle al campo del wrapper, quest'ultimo ne diventa il proprietario, e l'unica pulizia sul percorso di errore consiste nel liberare il wrapper. Il distruttore del wrapper chiama FPDF_CloseDocument al posto tuo, quindi una seconda chiusura esplicita causerebbe una doppia liberazione del medesimo documento. Il gestore di errori corretto rilascia l'oggetto e rilancia l'eccezione, definendo un unico percorso per la chiusura.

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

Sia i record gestiti sia una libreria ricca di funzioni esportate richiedono una disattivazione esplicita

L'ultima classe riguarda la memoria gestita dal compilatore al posto tuo, che l'abitudine al linguaggio C potrebbe compromettere in silenzio. Molte funzioni di supporto di questo binding restituiscono un record che contiene un tipo WideString o un array dinamico. Si tratta di campi con conteggio dei riferimenti, per i quali il compilatore genera operazioni nascoste per mantenerne aggiornati i valori. L'istinto derivato dal C spinge a pulire un record vuoto con FillChar(Result, SizeOf(Result), 0). Questa istruzione inserisce zeri sul riferimento gestito all'interno del record senza prima decrementarlo. Il compilatore riutilizza una variabile temporanea nascosta per il risultato della funzione tra le varie iterazioni del ciclo, quindi alla seconda iterazione FillChar sovrascrive un puntatore di stringa attivo che non è mai stato rilasciato, causando una perdita (leak) della stringa. Chiamando la funzione in un ciclo su mille annotazioni si causerà la perdita di mille stringhe.

La soluzione consiste nel lasciare che il linguaggio pulisca il record nel modo corretto tramite Default(T), che rilascia ogni campo gestito prima di azzerarlo.

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

Un problema correlato di proprietà risiede nel confine di caricamento della libreria. Questo binding risolve diverse centinaia di puntatori a funzione dalla DLL PDFium tramite GetProcAddress dopo una chiamata a LoadLibrary. Se manca una funzione esportata richiesta, lo stato parzialmente collegato è rischioso: decine di puntatori sono validi, i restanti sono nil o obsoleti, e qualsiasi chiamata successiva tramite uno di essi potrebbe indirizzare a un modulo già scaricato. Il binding gestisce questa situazione scaricando la libreria ed eseguendo una pulizia completa tramite ClearAllBindings, che reimposta a nil ogni puntatore importato qualora una funzione esportata richiesta non venga risolta. In questo modo, nessun puntatore a funzione farà riferimento a un modulo scaricato, e le chiamate successive falliranno in modo controllato con una verifica del puntatore nullo anziché indirizzare a porzioni di codice già liberate.

Il wrapper è il punto in cui quattro contratti vengono ridefiniti manualmente

Nessuno di questi cinque difetti è insolito. Rappresentano i prevedibili problemi di un sottile livello Pascal posizionato sopra un'API C, e si concentrano poiché tale livello è esattamente il punto in cui quattro contratti separati devono essere dichiarati nuovamente. La convenzione di chiamata deve essere definita come cdecl su ogni callback. La larghezza dell'intero deve corrispondere a size_t sulla piattaforma in cui questo si amplia. Il modello di eccezione deve essere convertito in codici di ritorno in ogni callback che esce dal contesto Pascal. La proprietà di ciascun handle e di ogni campo gestito deve essere definita chiaramente e rispettata in ogni percorso, compresi i percorsi di errore che nessuno testa prima della produzione. L'omissione di uno solo di questi aspetti produce un difetto il cui sintomo si manifesta lontano dalla causa originaria, rendendo questa categoria complessa da gestire. Il valore del controllo risiede non tanto nelle singole correzioni, quanto nell'aver considerato ciascuno di questi punti come una regola da verificare sull'intero binding.

Se desideri vedere il binding in azione piuttosto che l'analisi delle sue protezioni, le tecniche di cache di rendering e zoom descritte nella nostra nota sulle prestazioni di cache di rendering e zoom mostrano il percorso di rendering, e la guida alla compilazione multipiattaforma per la creazione di un visualizzatore con Lazarus e FPC è la sezione in cui il comportamento di size_t su Win64 qui analizzato ha un impatto reale. Entrambi si basano sulle stesse soluzioni di sicurezza della memoria e ABI incluse nel componente PDFium Component per Delphi, Lazarus e C++Builder, insieme alle API di rendering, estrazione del testo e gestione dei moduli descritte in altre sezioni di questo blog.