Article technique

Durcissement d'une liaison de composant PDFium : ABI et sécurité de la mémoire

Une liaison Pascal (binding) sur une bibliothèque C se lit comme du Pascal ordinaire. Vous appelez une méthode, vous récupérez un enregistrement (record), vous libérez ce que vous avez alloué. Le problème est que PDFium est une bibliothèque C et C++ avec sa propre convention d'appel, ses propres largeurs d'entiers et ses propres règles concernant qui possède la mémoire et qui la libère. Rien de tout cela ne franchit la frontière du langage par soi-même. Chacun de ces contrats doit être réénoncé à la main dans les déclarations Pascal, et un seul mot erroné transforme un appel d'apparence propre en une corruption de pile (stack corruption), un décalage tronqué ou une double libération (double free). Un audit de la version 1.61.0 d'une liaison de Composant PDFium a révélé un défaut de chaque type. Il vaut la peine de les examiner car ils ne sont pas spécifiques à cette liaison. Ce sont les risques permanents liés à l'encapsulation (wrapping) de n'importe quelle API C dans Delphi ou Lazarus

cdecl fait partie du type de fonction, pas d'une décoration

PDFium est du C compilé. Sur Win32, ses exportations et, plus important encore, les rappels (callbacks) qu'il invoque utilisent la convention d'appel cdecl. Sous cdecl, l'appelant nettoie la pile après le retour de l'appel. La valeur par défaut native de Delphi est register, et la norme C Win32 pour les rappels est stdcall dans certaines bibliothèques, où c'est l'appelé qui nettoie à la place. Lorsqu'une structure transmet à PDFium un pointeur de fonction et que vous oubliez le cdecl sur le type de ce pointeur, les deux parties ne sont pas d'accord sur qui ajuste le pointeur de pile. Soit les deux le corrigent, soit aucun ne le fait, et le pointeur de pile dérive de la taille des arguments à chaque invocation

La raison pour laquelle ce défaut est difficile à trouver est que les dommages ne sont pas locaux. L'appel corrompu se termine et semble correct. Le désalignement apparaît plus tard, dans une fonction non liée dont le cadre de pile (frame) se trouve désormais sur un pointeur de pile décalé de quelques octets, et il se manifeste par une lecture folle (wild read), une mauvaise adresse de retour ou un plantage avec une trace d'exécution (backtrace) qui ne pointe nulle part près du rappel sur lequel vous vous êtes trompé. Le remplissage de formulaire (form-fill) est l'endroit classique où cela pose problème, car l'interface de remplissage de formulaire est un enregistrement rempli de rappels que PDFium rappelle. L'un d'eux, FFI_OpenFile, transmet à PDFium une fonction qu'il appellera pour ouvrir un fichier externe, déclarée comme function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Le cdecl final est le point qu'il vaut la peine de copier. Supprimez-le et le code compile toujours, se lie toujours et s'exécute toujours correctement jusqu'à ce que PDFium appelle la fonction. La convention appartient au type de fonction lui-même. Ce n'est pas du sucre syntaxique optionnel, et le compilateur ne vous avertira pas de son absence, car un type de fonction simple est un type Pascal parfaitement légal. La seule défense consiste à traiter la convention d'appel comme un champ obligatoire de chaque signature importée et de chaque rappel que vous transmettez vers l'extérieur

size_t a la largeur d'un pointeur, et sur FPC Win64, cela signifie 64 bits

Le deuxième défaut est une non-correspondance de la largeur des entiers qui n'apparaît que sur une seule cible. Le size_t de C est défini pour être suffisamment large pour contenir n'importe quelle taille d'objet, ce qui sur une plate-forme 64 bits signifie un entier non signé de 64 bits. Les interfaces de chargement progressif de PDFium s'expriment en décalages d'octets (byte offsets) size_t. L'enregistrement FX_FILEAVAIL du fournisseur de disponibilité porte un rappel IsDataAvail que PDFium appelle avec un décalage et une taille, et le rappel AddSegment de l'enregistrement FX_DOWNLOADHINTS reçoit la même chose. Les deux paramètres sont des size_t

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

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

Si vous déclarez ces décalages comme un type 32 bits, la liaison fonctionne sur Win32 et sur Delphi Win64, puis se casse silencieusement sur FPC et Lazarus Win64. La cause est subtile. Sur FPC Win64, NativeUInt est un véritable type 64 bits de la largeur d'un pointeur, et size_t y est un alias. La liaison contient un commentaire dans la section des types avertissant précisément de ne pas masquer (shadowing) NativeUInt sur FPC, car le redéfinir en un alias 32 bits forcerait size_t à 32 bits et corromprait chaque paramètre size_t transmis ou écrit par la bibliothèque. Un décalage 64 bits arrivant sur un paramètre 32 bits perd sa moitié supérieure. Pour un petit fichier, chaque décalage tient sur 32 bits et rien ne cloche. Pour un fichier volumineux, dès qu'un décalage franchit la ligne des quatre gigaoctets, la valeur tronquée pointe vers un tout autre endroit, PDFium demande si la mauvaise plage d'octets est disponible, et le chargement progressif se bloque ou lit des données inutilisables. Le défaut est invisible jusqu'à ce que le fichier soit assez gros et que la cible soit celle où size_t s'est réellement élargi

Une exception Pascal ne doit jamais se dérouler (unwind) à travers un cadre C (C frame)

La troisième classe concerne le modèle d'exception, que C n'a pas. Lorsque PDFium appelle l'un de vos rappels, votre code Pascal s'exécute à l'intérieur d'une pile de cadres C et C++ qui ne connaissent rien à la machinerie d'exceptions de Delphi. Si votre rappel lève une exception et la laisse se propager, elle se déroule à travers des cadres qui n'ont jamais été conçus pour cela. Le propre nettoyage de PDFium ne s'exécute pas, ses invariants internes sont laissés à moitié mis à jour et le processus se trouve désormais dans un état que la bibliothèque n'a jamais anticipé. Le contrat pour ces rappels est un code de retour, pas une exception

Deux rappels rendent cela concret. FPDF_FILEWRITE est le récepteur (sink) dans lequel PDFium écrit un document enregistré, et FPDF_FILEACCESS est la source à partir de laquelle il lit un document d'entrée. Tous deux sont implémentés ici sur un TStream Delphi, et tous deux peuvent échouer de la même manière que n'importe quel flux échoue : le disque se remplit, le flux est fermé sous vos pieds, une lecture dépasse la fin. Le rappel d'écriture enveloppe son écriture de flux et transforme tout échec en code d'échec de PDFium au lieu de le laisser s'échapper

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium traite tout retour différent de 1 comme un échec d'écriture. Une exception Pascal
  // ne doit pas se dérouler à travers ce cadre cdecl/C++, il faut donc la piéger et signaler
  // l'échec à la place.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

Le côté lecture fait de même : une lecture ayant échoué signale zéro pour correspondre au contrat FPDF_FILEACCESS au lieu de se déclencher à travers la frontière. Un except nu sans relance (re-raise) semble faux pour un programmeur Pascal formé à ne jamais masquer (swallow) les exceptions, et dans le Pascal ordinaire, c'est faux. À une frontière ABI, c'est la forme correcte, car la seule valeur sûre à renvoyer à l'appelant C est un code d'état qu'il sait interpréter. L'échec se propage toujours, mais par le biais de la valeur de retour, et le code d'appel au-dessus de la bibliothèque le fait remonter sous la forme d'une EPdfError une fois que le contrôle est de retour du côté Pascal de la barrière

Une double libération se cache sur le chemin d'erreur

Le quatrième défaut concerne la propriété (ownership). Un descripteur (handle) de document PDFium est ouvert par la bibliothèque et doit être fermé exactement une fois, par FPDF_CloseDocument. Le danger est un chemin d'erreur qui libère un descripteur qu'un deuxième nettoyage possède également. Imaginez une routine qui crée un objet enveloppe (wrapper), lui attribue un descripteur de document fraîchement ouvert, puis effectue davantage de configuration susceptible d'échouer. Si la configuration lève une exception, un gestionnaire de retour anticipé qui appelle FPDF_CloseDocument sur le descripteur brut le fermera, et ensuite le propre destructeur de l'objet enveloppe le fermera à nouveau lorsque l'objet sera libéré. Le descripteur est libéré deux fois, ce qui est un comportement indéfini (undefined behavior) et un plantage probable

L'audit a révélé cela sur un chemin d'importation de type imposition qui construit un TPdf autour d'un descripteur déjà ouvert. La solution consiste à faire du transfert de propriété la seule source de vérité. Une fois le descripteur attribué au champ de l'enveloppe, l'enveloppe le possède, et le seul nettoyage sur le chemin d'erreur consiste à libérer l'enveloppe. Le destructeur de l'enveloppe appelle FPDF_CloseDocument pour vous, de sorte qu'une deuxième fermeture explicite libérerait deux fois le même document. Le gestionnaire d'erreurs corrigé libère l'objet et relance l'exception, et il n'y a qu'un seul chemin vers la fermeture

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result possède désormais le descripteur
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free ferme le descripteur. Un deuxième FPDF_CloseDocument(NewDoc)
  // ici libérerait deux fois le même document PDFium.
  Result.Free;
  raise;
end;

Les enregistrements gérés (managed records) et une bibliothèque pleine d'exportations nécessitent tous deux un démontage (teardown) explicite

La dernière classe concerne la mémoire que le compilateur gère en votre nom, qu'une habitude du C corrompra silencieusement. Beaucoup des fonctions d'assistance de cette liaison renvoient un enregistrement qui contient une WideString ou un tableau dynamique. Ce sont des champs comptabilisés par référence, et le compilateur émet une comptabilité cachée pour maintenir leurs comptes. L'instinct hérité du C est d'effacer un enregistrement frais avec FillChar(Result, SizeOf(Result), 0). Cela inscrit des zéros sur la référence gérée à l'intérieur de l'enregistrement sans la décrémenter au préalable. Le compilateur réutilise un élément temporaire caché pour un résultat de fonction au fil des itérations de boucle, donc à la deuxième itération, FillChar écrase un pointeur de chaîne actif qui n'a jamais été libéré, et la chaîne vers laquelle il pointait fuit (leaks). Appelez la fonction dans une boucle sur un millier d'annotations et vous ferez fuir un millier de chaînes

La solution consiste à laisser le langage effacer l'enregistrement de la manière qu'il connaît, avec Default(T), qui libère tout champ géré avant de le mettre à zéro

// Default() au lieu de FillChar : le compilateur réutilise un temporaire caché pour
// le résultat de la fonction au travers des itérations de la boucle, donc FillChar mettrait à zéro les pointeurs
// WideString actifs sans les libérer.
Result := Default(TPdfAnnotation);

Un problème de propriété connexe se situe à la limite de chargement de la bibliothèque. Cette liaison résout plusieurs centaines de pointeurs de fonctions de la DLL PDFium avec GetProcAddress après un LoadLibrary. Si une exportation requise est manquante, l'état partiellement lié est dangereux : des dizaines de pointeurs sont valides, le reste est nul ou périmé, et tout appel ultérieur via l'un d'eux saute dans un module qui peut déjà être déchargé. La liaison gère cela en déchargeant la bibliothèque et en exécutant un ClearAllBindings complet qui réinitialise chaque pointeur importé à nil chaque fois qu'une exportation requise ne parvient pas à être résolue. Après cela, aucun pointeur de fonction ne pend (dangles) dans un module déchargé, et un appel ultérieur échoue proprement avec une vérification de pointeur nul au lieu de bifurquer dans du code libéré

L'enveloppe est l'endroit où quatre contrats sont réénoncés à la main

Aucun de ces cinq défauts n'est exotique. Ce sont les modes de défaillance prévisibles d'une fine couche Pascal sur une API C, et ils se regroupent car cette couche est exactement l'endroit où quatre contrats distincts doivent être redéclarés. La convention d'appel doit être orthographiée cdecl sur chaque rappel. La largeur d'entier doit correspondre à size_t sur la seule cible où elle s'élargit réellement. Le modèle d'exception doit être converti en codes de retour à chaque rappel qui sort du Pascal. La propriété de chaque descripteur et de chaque champ géré doit être déclarée une fois et respectée sur chaque chemin, y compris les chemins d'erreur que personne n'emprunte jusqu'à la production. Si vous en manquez un, vous obtenez un défaut dont le symptôme apparaît loin de sa cause, c'est ce qui rend cette catégorie coûteuse. La valeur de l'audit résidait moins dans une correction unique que dans le traitement de chacune d'elles comme sa propre discipline à vérifier sur l'ensemble de la liaison

Si vous souhaitez voir la liaison effectuer un vrai travail plutôt que de surveiller ses bords, les techniques de cache de rendu (render-cache) et de zoom dans notre note sur les performances du cache de rendu et du zoom montrent le chemin de rendu, et la présentation du compilateur croisé dans la construction d'un visualiseur Lazarus et FPC est l'endroit où le comportement Win64 size_t décrit ici a réellement de l'importance. Les deux s'appuient sur le même travail de sécurité de la mémoire et d'ABI qui est livré dans le Composant PDFium pour Delphi, Lazarus et C++Builder, à côté des API de rendu, d'extraction de texte et de formulaire abordées ailleurs sur ce blog