Un PDF n'est pas un simple document que l'on ouvre. C'est un petit programme que l'on exécute. Chaque police incorporée est un interpréteur à base de pile en attente de chaînes de caractères (charstrings), chaque image est traitée par un décodeur alimenté en paramètres de largeur, hauteur et profondeur de bits définis par le fichier, et chaque flux de données arrive enveloppé dans des filtres dont le fichier a défini les paramètres. Aucune de ces valeurs numériques n'est sous votre contrôle. Elles proviennent du créateur du fichier, qui, en situation réelle, peut être une facture client ou une pièce jointe d'un expéditeur inconnu. Les décodeurs qui traduisent ces octets en pixels et en glyphes constituent la surface d'attaque, et un analyseur qui leur accorde sa confiance aveugle s'expose à un plantage ou pire à la moindre anomalie.
PDFlibPas a fait l'objet d'une phase de sécurisation qui a traité l'ensemble du chemin de décodage comme hostile, depuis les programmes de polices (TrueType, Type1, CFF et tables CMap) jusqu'aux décodeurs d'images (PNG, GIF, TIFF, JBIG2 et CCITT Groupe 3 et Groupe 4) et aux filtres de flux (LZW, ASCII85 et prédicteurs Flate). Voici cinq catégories de défauts corrigés, chacun lié au comportement spécifique de Delphi qui le rendait possible. Ils ont été résolus dans les versions actuelles, et ces structures de bugs se retrouvent dans tout code Pascal analysant des entrées non fiables.
Un dépassement d'entier qui génère un tampon sous-dimensionné
Le bug classique de sécurité mémoire dans un décodeur d'images est un produit de dimensions qui déborde. Un décodeur lit la largeur, la hauteur, le nombre de composants et la profondeur de bits, les multiplie pour dimensionner sa sortie, alloue ce nombre d'octets, puis écrit l'image à ses dimensions réelles. Si la multiplication s'effectue en arithmétique 32 bits, le produit peut boucler vers une petite valeur même si chaque facteur individuel reste cohérent. L'allocation réussit alors, mais s'avère bien trop petite, et le décodage écrit au-delà des limites du tampon. Il s'agit de la faille CWE-190 (dépassement d'entier), qui entraîne une écriture hors limites dans le tas (CWE-787) à l'étape suivante.
Le chemin d'image partagé limitait déjà chaque dimension à 65535, mais les décodeurs autonomes n'en héritaient pas tous. Une expression de type octets par ligne multipliés par la hauteur comme ByteCount * FHeight, ou une expression par pixel comme FWidth * Components * BitDepth, est un produit 32 bits dans Delphi lorsque les deux opérandes sont des entiers 32 bits, quelle que soit la largeur de la variable à laquelle vous affectez le résultat. Une largeur et une hauteur de 60000 sont plausibles pour une grande numérisation, mais leur produit en octets dépasse la plage signée de 32 bits, produisant une faible longueur. Le même piège existait dans le pas du prédicteur ZLib, BitsPerComponent * Colors * Columns.
La correction consiste à convertir au moins un opérande en Int64 afin que toute l'expression soit évaluée sur 64 bits, puis à la comparer avec MaxInt pour rejeter le fichier avant de la réduire à nouveau pour appeler SetLength.
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
Ce qui fait de ce problème une spécificité Delphi plutôt qu'un cas général est la réduction silencieuse de type. Affecter une expression trop large à une destination 32 bits est une conversion acceptée pour laquelle le compilateur n'émet aucun avertissement par défaut, et la vérification des limites (range checking) ne détecte pas un dépassement qui survient avant que la valeur ne soit exploitée comme index. Si vous laissez le produit en 32 bits, le langage produit silencieusement une longueur erronée sur la quantité de mémoire que le décodage va manipuler.
Un type de champ qui rend une condition de sécurité impossible à déclencher
Un fichier TIFF est une chaîne de répertoires d'images (image file directories), chacun transportant le décalage (offset) du suivant. Un fichier malveillant peut boucler cette chaîne sur elle-même, et un lecteur qui la parcourt sans condition d'arrêt s'exécute indéfiniment. C'est la faille CWE-835 (boucle infinie pilotée par une entrée contrôlée par l'attaquant), et la défense consiste en un compteur qui s'arrête dès qu'il dépasse une limite qu'aucun fichier légitime ne saurait atteindre.
Le compteur de pages était déclaré comme un Word, qui dans Delphi stocke des valeurs de 0 à 65535. La boucle comportait une garde d'arrêt de la forme "s'arrêter lorsque le nombre de pages dépasse 65535", ce qui semble correct jusqu'à ce que l'on remarque que l'opérande et le seuil partagent la même limite supérieure. Un Word ne pouvant jamais dépasser 65535, la comparaison est structurellement toujours fausse : lorsque le compteur atteint 65535, l'incrément suivant le ramène à 0, la garde ne détecte jamais de valeur supérieure au plafond et une boucle infinie de répertoires IFD maintient le lecteur actif.
La correction a consisté à élargir le type du champ pour que la garde puisse tester une valeur que le compteur peut effectivement atteindre. Avec TPDFTIFF.FPageCount déclaré comme Integer, la comparaison FPageCount > 65535 devient possible, la boucle se termine et la propriété publique PageCount a changé de type pour correspondre sans rompre la compatibilité avec les appelants. Chaque fois qu'une vérification de limite prend la forme Value > MaxValueOfType(Value) alors que l'opérande est déjà typé à ce maximum, la condition est constamment fausse : élargissez le type, ou testez l'égalité par rapport au maximum pour permettre le déclenchement.
Vérification de limites désactivée sur un chemin critique
Lorsque la vérification des limites est activée, Delphi insère un contrôle sur chaque index de tableau et de chaîne, ce qui fait la différence entre un index hors limites levant une exception interceptable ERangeError et ce même index lisant ou écrivant dans une zone mémoire n'appartenant pas à la structure. Les chemins critiques (hot paths) la désactivent parfois via la directive locale {$R-}, ce qui est acceptable tant que les index restent fiables.
L'accès aux listes sur lequel s'appuient les interpréteurs de polices, TPDFlibStringList.Get, est précisément un tel chemin. Sur Windows, il est compilé avec la vérification des limites désactivée et indexe directement son stockage sous-jacent. Un index hors limites ne génère donc pas d'erreur mais un accès direct à la mémoire. Cela convient tant que l'index est toujours valide, mais cesse de convenir au sein d'un interpréteur de chaînes de caractères (charstrings) CFF ou Type2, où l'index provient du fichier. Une chaîne de caractères qui retire un opérande d'une pile vide génère un index de moins un ; un identifiant de glyphe décalé de un par rapport au nombre de glyphes indexe un emplacement au-delà de la fin. Avec la vérification désactivée, les deux cas provoquent un réel accès hors limites au lieu d'une exception interceptable, et comme les emplacements contiennent des valeurs AnsiString à comptage de références, une lecture égarée peut également corrompre le compteur de références d'une chaîne.
La sécurisation n'a pas réactivé la vérification des limites sur le chemin critique. Elle a d'abord rendu les index vérifiables : avant de récupérer l'élément supérieur de la pile d'opérandes, l'interpréteur s'assure qu'elle n'est pas vide, et chaque garde d'index a été formulée avec une inégalité stricte "inférieur à" par rapport au compteur, plutôt qu'un "inférieur ou égal" acceptant l'erreur d'un décalage de un. La directive transfère la responsabilité des limites du compilateur vers le développeur, et la validation retirée doit être réimplémentée manuellement à chaque point d'entrée.
Récursion non limitée dans un interpréteur de chaînes de caractères
Une chaîne de caractères de type Type2 peut appeler un sous-programme, qui est lui-même une chaîne de caractères pouvant en appeler une autre. Les opérateurs d'appel locaux et globaux de sous-programmes permettent ainsi au fichier de décider de la profondeur d'imbrication. Un sous-programme qui s'appelle lui-même, directement ou par cycle, provoque une récursion infinie jusqu'à épuisement de la pile native et arrêt du processus. C'est la faille CWE-674 (récursion non contrôlée).
L'interpréteur Type1 intégrait déjà un garde-fou. Il comportait un compteur de profondeur d'appel et un plafond, PLType1MaxCallDepth, et refusait de descendre au-delà, ce qui correspond à la limite spécifiée par la norme Type1 elle-même. L'interpréteur Type2, ajouté ultérieurement et structurellement similaire, ne disposait pas de cette garde, et une police forgée manuellement comportant un sous-programme appelant son propre numéro contournait le contrôle manquant pour provoquer un dépassement de pile.
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
La correction a consisté à attribuer au chemin Type2 la même limite de profondeur que son homologue Type1. Tout parcours récursif de structures contrôlées par un attaquant (sous-programmes de polices, tableaux imbriqués ou chaînes de références croisées) nécessite un plafond de profondeur que l'entrée ne peut pas modifier.
Mémoire non initialisée qui fuite dans la sortie
Le défaut le plus subtil entraînait la fuite de contenu du tas dans les données déchiffrées. La cause réside dans une propriété de SetLength facile à oublier. Lorsque vous augmentez la taille d'une AnsiString avec SetLength, Delphi alloue les octets sans les initialiser à zéro, de sorte que la nouvelle zone conserve les anciennes valeurs de la mémoire du tas. Si chaque octet est écrit par la suite, cela n'a aucune incidence ; mais si un chemin laisse une partie du tampon non écrite et la renvoie comme donnée finale, ces octets obsolètes sont transmis avec le résultat. Il s'agit de la faille CWE-457 (utilisation de mémoire non initialisée), qui se transforme en fuite d'informations (information leak) lorsque le résultat traverse une frontière de confiance.
Le chemin de déchiffrement AES-CBC a rencontré précisément ce cas de figure. Le tampon de sortie était dimensionné avec SetLength et le déchiffreur traitait le texte chiffré par blocs de 16 octets. Lorsque la longueur du texte chiffré n'était pas un multiple de 16 (longueur qu'un attaquant peut définir librement), le bloc partiel final n'était jamais écrit, de sorte que ces derniers octets conservaient les valeurs résiduelles du tas laissées par SetLength, le tampon étant ensuite renvoyé comme le texte clair décrypté d'un objet document. La correction apporte deux protections, dont aucune ne suffit à elle seule : le point d'entrée de déchiffrement rejette désormais tout texte chiffré dont la longueur n'est pas un multiple de la taille du bloc, et à titre préventif, le tampon de sortie est mis à zéro avec FillChar avant utilisation afin que tout chemin échouant à écrire une zone renvoie des zéros plutôt que des résidus du tas.
Ce que il reste de cette phase de sécurisation
Ces cinq défauts sont différents mais se ressemblent. Une largeur d'entier qui boucle lors d'un produit, un type de champ qui condamne une vérification à être constamment fausse, un contrôle de limites désactivé là où les index cessent d'être sûrs, une récursion sans limite et un tampon que le langage omet de mettre à zéro. Dans chaque cas, Delphi a fait exactement ce pour quoi il est défini : le langage permet les débordements arithmétiques, les réductions de types silencieuses, la désactivation des contrôles d'index, la récursion sans limites intégrées et l'allocation sans initialisation. C'est le contrat de base, et un analyseur Pascal y répond en prenant manuellement en charge quatre éléments à chaque frontière contrôlée par le fichier : la largeur des entiers, la vérification des limites, la profondeur de récursion et l'initialisation des tampons.
Ces défauts sont corrigés dans les versions actuelles de PDFlibPas, le moteur pour Delphi et C++Builder. Si vos développements impliquent également de valider la protection revendiquée par un fichier, les notes complémentaires sur l'audit du chiffrement et des autorisations et sur le contrôle préalable PDF/A et PDF/UA détaillent les fonctions d'analyse du même décodeur. Tout cela est fourni dans la bibliothèque PDF Delphi PDFlibPas, aux côtés des API de chargement, de rendu et de signature abordées sur ce blog.