Technical Article

Härtung eines PDFium-VCL-Bindings: ABI und Speichersicherheit

Ein Pascal-Binding über eine C-Bibliothek liest sich wie gewöhnliches Pascal. Sie rufen eine Methode auf, erhalten einen Record zurück und geben frei, was Sie reserviert haben. Das Problem ist, dass PDFium eine C- und C++-Bibliothek mit einer eigenen Aufrufkonvention, eigenen Ganzzahlbreiten und eigenen Regeln darüber ist, wer Speicher besitzt und wer ihn freigibt. Nichts davon überschreitet die Sprachgrenze von selbst. Jeder dieser Verträge muss in den Pascal-Deklarationen von Hand neu formuliert werden, und ein einziges falsches Wort verwandelt einen sauber aussehenden Aufruf in eine Stack-Korruption, einen abgeschnittenen Offset oder ein doppeltes Freigeben. Eine Prüfung eines PDFium-VCL-Bindings der Version 1.61.0 brachte einen Fehler jeder Art zutage. Es lohnt sich, diese durchzugehen, da sie nicht spezifisch für dieses Binding sind. Sie sind die permanenten Gefahren beim Verpacken jeder C-API in Delphi oder Lazarus

cdecl ist Teil des Funktionstyps, keine Dekoration

PDFium is kompiliertes C. Unter Win32 verwenden seine Exporte und, was noch wichtiger ist, die von ihm aufgerufenen Callbacks die Aufrufkonvention cdecl. Unter cdecl räumt der Aufrufer den Stack auf, nachdem der Aufruf zurückgekehrt ist. Delphis nativer Standard ist register, und the Win32 C-Standard für Callbacks ist in einigen Bibliotheken stdcall, bei dem stattdessen der Aufgerufene aufräumt. Wenn eine Struktur PDFium einen Funktionszeiger übergibt und Sie das cdecl beim Typ dieses Zeigers vergessen, sind sich die beiden Seiten uneinig darüber, wer den Stack-Pointer anpasst. Beide korrigieren es, oder keiner tut es, und der Stack-Pointer driftet bei jedem Aufruf um die Größe der Argumente ab

Der Grund, warum diese Schwachstelle schwer zu finden ist, liegt darin, dass der Schaden nicht lokal begrenzt ist. Der beschädigte Aufruf kehrt zurück und sieht gut aus. Die Fehlstellung zeigt sich erst später in einer völlig unbeteiligten Funktion, deren Frame nun auf einem Stack-Pointer sitzt, der um einige Bytes verschoben ist. Dies äußert sich in unkontrollierten Lesevorgängen, einer falschen Rücksprungadresse oder einem Absturz mit einem Backtrace, der nirgendwo in die Nähe des Callbacks zeigt, den Sie eigentlich falsch deklariert haben. Das Ausfüllen von Formularen (Form-Fill) ist der klassische Ort, an dem dies auftritt, da die Form-Fill-Schnittstelle ein Record voller Callbacks ist, in die PDFium zurückruft. Einer davon, FFI_OpenFile, übergibt PDFium eine Funktion, die zum Öffnen einer externen Datei aufgerufen wird, deklariert als function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Das abschließende cdecl ist der entscheidende Punkt. Lassen Sie es weg, kompiliert der Code immer noch, linkt immer noch und läuft so lange, bis PDFium die Funktion tatsächlich aufruft. Die Konvention gehört zum Funktionstyp selbst. Sie ist kein optionales Extra, und der Compiler warnt Sie nicht, wenn sie fehlt, da ein einfacher Funktionstyp ein völlig legaler Pascal-Typ ist. Die einzige Verteidigung besteht darin, die Aufrufkonvention als Pflichtfeld jeder importierten Signatur und jedes Callbacks zu behandeln, den Sie nach außen übergeben

size_t hat Zeigerbreite, und unter FPC Win64 bedeutet das 64 Bit

Die zweite Schwachstelle ist ein Breitenkonflikt bei Ganzzahlen, der nur auf einem Zielsystem auftritt. Das size_t in C ist so definiert, dass es breit genug ist, um jede Objektgröße aufzunehmen, was auf einer 64-Bit-Plattform eine vorzeichenlose 64-Bit-Ganzzahl bedeutet. Die progressiven Ladeschnittstellen von PDFium arbeiten mit Byte-Offsets vom Typ size_t. Der FX_FILEAVAIL-Record des Verfügbarkeitsanbieters trägt einen IsDataAvail-Callback, den PDFium mit einem Offset und einer Größe aufruft, und der AddSegment-Callback des FX_DOWNLOADHINTS-Records erhält dasselbe. Beide Parameter sind vom Typ size_t

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

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

Wenn Sie diese Offsets als 32-Bit-Typ deklarieren, das Binding unter Win32 und Delphi Win64 funktioniert, bricht jedoch unter FPC und Lazarus Win64 stillschweigend ab. Die Ursache ist subtil. Unter FPC Win64 ist NativeUInt ein echter 64-Bit-Typ mit Zeigerbreite, und size_t ist ein Alias dafür. Das Binding enthält im Typabschnitt einen Kommentar, der genau davor warnt, NativeUInt unter FPC zu überschatten. Eine Neudefinition als 32-Bit-Alias würde size_t dort auf 32 Bit zwingen und jeden an die Bibliothek übergebenen oder von ihr geschriebenen size_t-Parameter beschädigen. Ein 64-Bit-Offset, der an einem 32-Bit-Parameter ankommt, verliert seine obere Hälfte. Bei einer kleinen Datei passt jeder Offset in 32 Bit und alles ist gut. Bei einer großen Datei zeigt der abgeschnittene Wert, sobald ein Offset die Vier-Gigabyte-Grenze überschreitet, an einen völlig falschen Ort. PDFium fragt, ob der falsche Bytebereich verfügbar ist, und das progressive Laden stockt oder liest Müll. Der Fehler bleibt unsichtbar, bis die Datei groß genug ist und das Zielsystem dasjenige ist, auf dem sich size_t tatsächlich verbreitert hat

Eine Pascal-Exception darf niemals durch einen C-Frame abgewickelt (unwind) werden

Zwei Callbacks machen dies konkret. FPDF_FILEWRITE is die Senke, in die PDFium ein gespeichertes Dokument schreibt, und FPDF_FILEACCESS ist die Quelle, aus der es ein Eingabedokument liest. Beide sind hier über einen Delphi-TStream implementiert, und beide können so fehlschlagen, wie jeder Stream fehlschlägt: Die Festplatte läuft voll, der Stream wird unter Ihnen geschlossen, ein Lesevorgang läuft über das Ende hinaus. Der Schreib-Callback umschließt seinen Stream-Schreibvorgang und wandelt jeden Fehler in den Fehlercode von PDFium um, anstatt ihn entkommen zu lassen

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;

Die Leseseite tut dasselbe: Ein fehlgeschlagener Lesevorgang meldet Null zurück, um dem FPDF_FILEACCESS-Vertrag zu entsprechen, anstatt eine Ausnahme über die Grenze hinweg auszulösen. Ein nacktes except ohne erneutes Auslösen (re-raise) sieht für einen Pascal-Programmierer, der gelernt hat, Ausnahmen niemals einfach zu schlucken, falsch aus, und im normalen Pascal ist es das auch. An einer ABI-Grenze ist es jedoch die korrekte Struktur, da der einzige sichere Wert, der an den C-Aufrufer zurückgegeben werden kann, ein Statuscode ist, den dieser zu interpretieren weiß. Der Fehler pflanzt sich immer noch fort, nur eben über den Rückgabewert, und der aufrufende Code über der Bibliothek bringt ihn als EPdfError an die Oberfläche, sobald die Kontrolle wieder auf der Pascal-Seite liegt

Doppelte Freigabe versteckt sich auf dem Fehlerpfad

Die vierte Schwachstelle betrifft den Besitz. Ein PDFium-Dokumenten-Handle wird von der Bibliothek geöffnet und muss genau einmal geschlossen werden, und zwar durch FPDF_CloseDocument. Die Gefahr besteht in einem Fehlerpfad, der ein Handle freigibt, das auch von einer zweiten Bereinigung besessen wird. Stellen Sie sich eine Routine vor, die ein Wrapper-Objekt erstellt, ihm ein frisch geöffnetes Dokumenten-Handle zuweist und dann weitere Einstellungen vornimmt, die fehlschlagen könnten. Wenn die Einrichtung eine Ausnahme auslöst, schließt ein vorzeitiger Rückgabe-Handler, der FPDF_CloseDocument auf dem rohen Handle aufruft, dieses. Anschließend schließt der eigene Destruktor des Wrapper-Objekts dieses erneut, wenn das Objekt freigegeben wird. Das Handle wird zweimal freigegeben, was ein undefiniertes Verhalten und einen wahrscheinlichen Absturz zur Folge hat

Die Überprüfung fand dies bei einem Importpfad im Imposition-Stil, der ein TPdf um ein bereits geöffnetes Handle herum aufbaut. Die Behebung besteht darin, den Besitzübergang zur einzigen Source of Truth zu machen. Sobald das Handle dem Feld des Wrappers zugewiesen ist, besitzt der Wrapper es, und die einzige Bereinigung auf dem Fehlerpfad besteht darin, den Wrapper freizugeben. Der Destruktor des Wrappers ruft FPDF_CloseDocument für Sie auf, sodass ein zweites explizites Schließen dasselbe Dokument doppelt freigeben würde. Der korrigierte Fehler-Handler gibt das Objekt frei und löst die Ausnahme erneut aus, sodass es genau einen Pfad zum Schließen gibt

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;

Verwaltete Records und eine Bibliothek voller Exporte erfordern beide einen expliziten Abbau

Die letzte Klasse betrifft Speicher, den der Compiler in Ihrem Namen verwaltet, den eine C-Gewohnheit jedoch unbemerkt beschädigen kann. Viele der Hilfsfunktionen dieses Bindings geben einen Record zurück, der einen WideString oder ein dynamisches Array enthält. Dies sind referenzgezählte Felder, und the Compiler erzeugt eine versteckte Buchhaltung, um deren Zähler zu verwalten. Der von C übernommene Instinkt besteht darin, einen neuen Record mit FillChar(Result, SizeOf(Result), 0) zu löschen. Dies schreibt Nullen über die verwaltete Referenz im Record, ohne sie zuvor zu dekrementieren. Der Compiler verwendet ein verstecktes temporäres Objekt für ein Funktionsergebnis über Schleifeniterationen hinweg wieder. Bei der zweiten Iteration überschreibt FillChar somit einen aktiven String-Zeiger, der nie freigegeben wurde, wodurch der String, auf den er zeigte, leckt. Rufen Sie die Funktion in einer Schleife über tausend Anmerkungen hinweg auf, lecken Sie tausend Strings

Die Lösung besteht darin, den Record von der Sprache so löschen zu lassen, wie sie es versteht, nämlich mit Default(T), was jedes verwaltete Feld freigibt, bevor es genullt wird

// 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);

Ein verwandtes Besitzproblem existiert an der Schnittstelle zum Laden der Bibliothek. Dieses Binding löst mehrere hundert Funktionszeiger aus der PDFium-DLL mit GetProcAddress nach einem LoadLibrary. Wenn ein erforderlicher Export fehlt, ist der teilweise gebundene Zustand gefährlich: Dutzende Zeiger sind gültig, der Rest ist nil oder veraltet, und jeder spätere Aufruf über einen davon springt in ein Modul, das möglicherweise bereits entladen ist. Das Binding löst dies, indem es die Bibliothek entlädt und ein vollständiges ClearAllBindings ausführt, das jeden importierten Zeiger wieder auf nil zurücksetzt, wann immer ein erforderlicher Export nicht aufgelöst werden kann. Danach baumelt kein Funktionszeiger mehr in ein entladenes Modul, und ein späterer Aufruf schlägt sauber mit einer nil-Zeiger-Prüfung fehl, anstatt in freigegebenen Code zu verzweigen

Im Wrapper werden vier Verträge von Hand neu formuliert

Keine dieser fünf Schwachstellen ist exotisch. Es sind die vorhersehbaren Fehlermodi einer dünnen Pascal-Schicht über einer C-API, und sie häufen sich, weil genau in dieser Schicht vier separate Verträge neu deklariert werden müssen. Die Aufrufkonvention muss bei jedem Callback mit cdecl angegeben werden. Die Ganzzahlbreite muss auf dem einen Zielsystem, auf dem sie sich tatsächlich verbreitert, mit size_t übereinstimmen. Das Ausnahmemodell muss bei jedem Callback, der Pascal verlässt, in Rückgabecodes umgewandelt werden. Der Besitz jedes Handles und jedes verwalteten Feldes muss einmal deklariert und auf jedem Pfad eingehalten werden, einschließlich der Fehlerpfade, die vor der Produktion niemand ausprobiert. Verpassen Sie einen davon, erhalten Sie einen Fehler, dessen Symptom sich weit entfernt von seiner Ursache zeigt, was diese Kategorie so teuer macht. Der Wert der Überprüfung lag weniger in einer einzelnen Fehlerbehebung als vielmehr darin, jeden dieser Punkte als eigene Disziplin zu behandeln, die über das gesamte Binding hinweg zu prüfen ist

Wenn Sie das Binding bei der echten Arbeit sehen möchten, anstatt nur seine Kanten abzusichern, zeigen die Render-Cache- und Zoom-Techniken in our note on render-cache and zoom performance den Rendering-Pfad. Die Cross-Compiler-Anleitung zum building a Lazarus and FPC viewer ist der Ort, an dem das hier beschriebene Win64-size_t-Verhalten tatsächlich eine Rolle spielt. Beide bauen auf der gleichen Speichersicherheits- und ABI-Arbeit auf, die in der PDFium Component für Delphi, Lazarus und C++Builder zusammen mit den Rendering-, Textextraktions- und Formular-APIs ausgeliefert wird, die an anderer Stelle auf diesem Blog behandelt werden