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
PDF Library for Delphi traite tout le chemin de décodage comme hostile, à travers les programmes de polices (TrueType, Type1, CFF et les tables CMap), les décodeurs d'images (PNG, GIF, TIFF, JBIG2 et CCITT Group 3 et Group 4) et les filtres de flux (LZW, ASCII85 et les prédicteurs Flate). Ce qui suit, ce sont cinq pièges de sécurité mémoire que ce chemin doit fermer, chacun ancré dans le comportement Delphi précis qui le rend possible, et les mêmes formes réapparaissent dans tout code Pascal qui analyse une entrée non fiable
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
Limiter chaque dimension à 65535 sur un chemin d'image partagé ne suffit pas quand un décodeur autonome calcule ses propres tailles. Une expression octets-par-ligne fois hauteur comme ByteCount * FHeight, ou une expression par pixel comme FWidth * Components * BitDepth, est un produit 32 bits en Delphi quand 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 chacune plausibles pour un grand scan, mais leur produit en octets déborde d'une plage 32 bits signée et la longueur ressort petite. Le même piège se cache dans un pas de prédicteur comme BitsPerComponent * Colors * Columns
La défense consiste à rendre au moins un opérande Int64 pour que toute l'expression soit évaluée en 64 bits, puis à comparer à MaxInt et refuser le fichier avant de redescendre en 32 bits pour appeler SetLength
// Rejeter avant d'allouer, pas après avoir écrit.
// Évaluer le produit en Int64 pour qu'il ne puisse pas déborder sur 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // dimensions hostiles ou non prises en charge ; refuser l'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
Déclarez à présent le compteur de pages comme Word, qui en Delphi stocke 0 à 65535, et donnez à la boucle une garde de terminaison de la forme « arrêt quand le nombre de pages dépasse 65535 ». Cela se lit comme correct jusqu'à ce que vous remarquiez que l'opérande et le seuil partagent la même borne supérieure. Un Word ne peut jamais être supérieur à 65535, donc la comparaison est structurellement toujours fausse : quand le compteur atteint 65535, l'incrément suivant le ramène à 0, la garde ne voit jamais de valeur au-dessus du plafond, et une chaîne d'IFD en boucle maintient le lecteur en rotation
Le remède consiste à élargir le champ pour que la garde puisse exprimer une valeur que le compteur peut réellement tenir. TPDFTIFF.FPageCount est déclaré Integer, donc la comparaison FPageCount > 65535 est atteignable et la boucle se termine. Chaque fois qu'un test de borne a la forme Value > MaxValueOfType(Value) et que l'opérande est déjà typé exactement à ce maximum, la condition est une constante fausse : élargissez le type, ou testez l'égalité avec le maximum pour qu'elle puisse se déclencher
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'accesseur de liste sur lequel s'appuient les interpréteurs de polices, TPDFlibStringList.Get, est exactement un tel chemin. Sous Windows il est compilé avec la vérification de limites désactivée et indexe son stockage sous-jacent directement, donc un index hors plage serait un accès mémoire brut plutôt qu'une erreur. C'est très bien quand l'index est toujours valide, et cela exige de la prudence dans un interpréteur de charstrings CFF ou Type2, où l'index peut venir du fichier. Un charstring qui dépile un opérande d'une pile vide produit un index de moins un ; un identifiant de glyphe décalé de un par rapport au nombre de glyphes indexe une case après la fin. Sans contrôle, les deux seraient un véritable accès hors limites au lieu d'une exception rattrapable, et comme les cases contiennent des valeurs AnsiString à compteur de références, une lecture égarée peut aussi corrompre le compteur de références d'une chaîne
PDF Library for Delphi garde la vérification de limites désactivée sur ce chemin critique et rend les indices prouvablement valides à la place : avant de prendre le sommet de la pile d'opérandes, l'interpréteur vérifie que la pile n'est pas vide, et chaque garde d'index est un strictement inférieur au compte plutôt qu'un inférieur-ou-égal qui admettrait le décalage de un. La directive déplace la responsabilité des limites du compilateur vers vous, et la validation qu'elle retire doit être remise à la main à 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 se prémunit contre cela avec un compteur de profondeur d'appel et un plafond, PLType1MaxCallDepth, et refuse de descendre au-delà, ce qui reflète la limite de profondeur que la spécification Type1 nomme elle-même. Le piège, c'est qu'un interpréteur structurellement semblable écrit plus tard, comme celui du Type2, n'hérite pas de cette garde tout seul, et une police artisanale avec un sous-programme qui s'appelle lui-même traverse directement le test manquant jusqu'à un débordement de pile
// La forme de la garde de profondeur dont les deux interpréteurs de charstrings ont besoin.
// Suivez la profondeur à travers les appels imbriqués et refusez de récurser au-delà.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // sous-programme auto-référentiel hostile ; arrêter la descente
// ... interpret the subroutine, then Dec(CallDepth) on the way out
PDF Library for Delphi donne au chemin Type2 la même profondeur bornée que son jumeau Type1. Toute descente récursive sur une structure contrôlée par l'attaquant, que ce soient des sous-programmes de polices, un tableau imbriqué ou une chaîne de références croisées, a besoin d'un plafond de profondeur que l'entrée ne peut pas relever
Mémoire non initialisée qui fuite dans la sortie
Le piège le plus subtil fait fuiter le contenu du tas dans la sortie déchiffrée, et la cause est une propriété de SetLength facile à oublier. Quand vous agrandissez un AnsiString avec SetLength, Delphi alloue les octets sans les mettre à zéro, si bien que la nouvelle zone contient ce qui se trouvait auparavant dans cette mémoire du tas. Si chaque octet est ensuite écrit, cela n'a jamais d'importance ; si un chemin laisse une partie du tampon non écrite puis la retourne comme donnée, ces octets périmés s'échappent avec le résultat. C'est la CWE-457, usage de mémoire non initialisée, et quand le résultat franchit une frontière de confiance, cela devient une fuite d'information
Un chemin de déchiffrement AES-CBC est exactement l'endroit où cela mord. Dimensionnez le tampon de sortie avec SetLength, traitez le texte chiffré bloc de 16 octets par bloc de 16 octets, et quand la longueur du texte chiffré n'est pas un multiple de 16 — longueur que l'attaquant peut choisir — le bloc partiel final n'est jamais écrit, donc ces derniers octets conservent le contenu du tas laissé par SetLength et le tampon repart comme texte clair déchiffré d'un objet document. Le remède tient en deux gardes, et aucun seul ne suffit : le point d'entrée de déchiffrement de PDF Library for Delphi rejette tout texte chiffré dont la longueur n'est pas un multiple de la taille de bloc, et en dernier recours la sortie est nettoyée avec FillChar avant usage pour que tout chemin qui omettrait d'écrire une zone retourne des zéros plutôt qu'un résidu du tas
Ce que les cinq pièges ont en commun
Les cinq pièges sont des bugs différents, mais ils riment. Une largeur entière qui boucle un produit, un type de champ qui voue une garde à une constante fausse, une vérification de limites désactivée là où les indices ont cessé d'être sûrs, une récursion sans plancher, et un tampon que le langage refuse de mettre à zéro. Dans chaque cas, Delphi fait exactement ce qu'il définit, parce que le langage vous donne une arithmétique qui boucle, un rétrécissement silencieux, des vérifications de limites que l'on peut couper, une récursion sans limite intégrée et une allocation qui n'initialise pas. Tel est le contrat, et un parser Pascal l'honore en prenant en charge à la main quatre choses à chaque frontière contrôlée par le fichier : largeur entière, vérification de limites, profondeur de récursion et initialisation des tampons
PDF Library for Delphi, le moteur pour Delphi et C++Builder, protège son chemin de décodage contre les cinq. Si votre travail touche aussi à la manière dont un fichier prétend être protégé, les notes compagnes sur l'audit du chiffrement et des permissions et sur le préflight PDF/A et PDF/UA couvrent le côté analyse du même parser, et le tout est livré dans PDF Library for Delphi aux côtés des API de chargement, de rendu et de signature couvertes ailleurs sur ce blog