Les recherches de sous-chaînes telles que Pos('/Length', Dict) constituent un mauvais choix pour lire un dictionnaire PDF car les clés de nom PDF partagent des préfixes : /Length est un préfixe de /Length1, et /Encrypt est un préfixe de /EncryptMetadata. La norme ISO 32000-1 §7.3.5 définit un nom comme un jeton (token) qui ne se termine que par un délimiteur ou un espace. Ainsi, une clé n'est considérée comme trouvée que si l'octet qui la suit est l'un de ces caractères. Un lecteur de dictionnaire qui omet cette vérification finira par lire une valeur incorrecte à partir d'un fichier pourtant parfaitement valide
Le bug qui nous a enseigné cela ne ressemblait en rien à un problème d'analyseur lexical. Une analyse de conformité a commencé à signaler un flux FontFile comme étant endommagé : le programme de police décompressé était fragmenté, tronqué au milieu d'une table. Le fichier s'ouvrait correctement dans tous les visualiseurs. Les données du flux sur le disque étaient intactes. La cause première résidait dans une ligne de notre lecteur de dictionnaire partagé : Pos('/Length', ...) avait correspondu à /Length1, une clé standard que les dictionnaires de flux FontFile portent selon le tableau 127 de la norme ISO 32000-1. Le lecteur avait alors interprété l'entier suivant /Length1 comme la longueur du flux. Le générateur de ce fichier particulier avait sérialisé /Length1 avant /Length, ce qu'il est tout à fait libre de faire puisque les entrées de dictionnaire ne sont pas ordonnées (§7.3.7). Le flux a été tronqué à un nombre d'octets incorrect, et chaque vérification en aval qui l'a consommé s'est retrouvée aveugle sans lever d'erreur
Pourquoi la recherche par sous-chaîne corrompt-elle l'analyse des dictionnaires PDF ?
La recherche par sous-chaîne échoue parce que l'espace des noms PDF regorge de familles de préfixes délibérées, et parce que l'ordre des entrées de dictionnaire n'est pas spécifié. Le tableau 127 de la norme ISO 32000-1 définit /Length1, /Length2 et /Length3 pour les flux de polices intégrées, tous cohabitant avec un /Length dans le même dictionnaire. Le dictionnaire de chiffrement associe /Encrypt dans la bande-annonce (trailer) à /EncryptMetadata en son sein. Les clés courtes sont encore plus problématiques : une recherche construite sous la forme Pos('/' + Key, ...) avec Key = 'N' correspondra joyeusement à /Name ou /Nums. Aucune de ces collisions ne requiert un fichier corrompu. Un générateur qui ordonne /Length1 avant /Length est tout à fait conforme, ce qui signifie que le bug de sous-chaîne n'est pas un défaut de robustesse face à une entrée incorrecte — c'est un défaut de logique face à une entrée valide
Ce mode de défaillance est également silencieux. Un /Length erroné ne lève pas d'exception ; il vous renvoie simplement une tranche d'octets plus courte ou plus longue que celle que le flux occupe réellement. Si cette tranche alimente une vérification de sous-ensemble de police, une analyse CMap ou un scan de métadonnées, le consommateur reçoit des données incorrectes et ne signale généralement rien du tout car la moitié d'un flux zlib échoue simplement à se décompresser et le code passe à la suite. Nous avons livré exactement cette classe de défaut et l'avons corrigée dans la version v2.14.3 de notre lecteur partagé, après qu'un audit clause par clause de la norme ISO 32000-1 §7.2–§7.3 a marqué chaque recherche de clé de style Pos comme suspecte
Ce que la norme ISO 32000-1 §7.3.5 définit réellement comme un nom
La section 7.3.5 est courte et précise : un objet nom est une barre oblique (solidus) suivie d'une séquence de caractères réguliers, et le jeton se termine par le premier délimiteur ou caractère d'espace. Les délimiteurs sont les huit caractères de crochets plus la barre oblique et le signe pourcentage — ( ) < > [ ] { } / % — et l'espace comprend le caractère nul, la tabulation, le saut de ligne, le saut de page, le retour chariot et l'espace (§7.2.2–§7.2.3). Cette règle de terminaison résume tout. /Length1 n'est pas « /Length suivi d'un 1 » ; c'est un jeton unique et indivisible, tout comme LengthOne et Length sont des identifiants différents en Pascal. Tout lecteur qui recherche des clés par simple recherche d'octets bruts réimplémente l'analyseur lexical en omettant la règle de terminaison
Voici la forme du défaut, réduit à l'essentiel. Cette version compile, passe les tests sur les fichiers dont les générateurs ordonnent /Length en premier, et corrompt les flux pour les générateurs qui font l'inverse
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Correspondance de jeton complet : vérifier l'octet après la clé
Le prédicat correct découle directement de la section 7.3.5 : une correspondance candidate n'est une vraie clé que si le caractère qui la suit immédiatement est un délimiteur, un espace ou la fin du tampon. Tout le reste est un nom plus long qui partage simplement un préfixe, la recherche doit donc se poursuivre au-delà au lieu de s'arrêter. La correction dans notre lecteur a consisté à remplacer chaque recherche brute Pos par une routine partagée unique basée sur cette règle
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
Deux détails de cette boucle sont importants. Premièrement, elle continue à chercher au lieu de renvoyer un échec dès la première collision de préfixe car /Length1 120 /Length 4076 est un ordonnancement légal et la vraie clé se trouve plus loin. Deuxièmement, le cas de fin de tampon compte comme un terminateur puisqu'un fragment de dictionnaire peut légitimement se terminer juste après un nom. Un point plus subtil à vérifier dans votre code : la même règle s'applique sur le côté gauche de la correspondance si votre chaîne de recherche n'inclut pas le solidus, sinon Pos('Length', ...) peut atterrir à l'intérieur de /PieceLength. Ancrer la chaîne de recherche avec le / initial, comme ci-dessus, gère le bord gauche car le / est lui-même un délimiteur qui termine le jeton précédent
Comment un PDF malveillant peut-il transformer un bug d'analyseur en une allocation de giga-octets ?
Un fichier malformé ou malveillant transforme ces erreurs lexicales en épuisement des ressources car les entiers de dictionnaires déterminent fréquemment les tailles d'allocation. Notre audit a révélé un enchaînement de ce type dans le traitement de flux d'objets (object streams). L'entrée /N d'un dictionnaire ObjStm indique le nombre d'objets compressés que contient le flux, et le code d'expansion appelait SetLength sur un tableau dimensionné par cette valeur. L'analyseur d'entier laissait toutefois son paramètre de sortie inchangé en cas d'échec tout en le renvoyant — de sorte qu'un /N non numérique transmettait à SetLength une valeur de pile non initialisée. Un entier positif erroné se traduit par une demande d'allocation de plusieurs giga-octets, déclenchée par quelques octets d'entrée corrompue, lors de la simple analyse d'un document
La correction comporte deux parties indépendantes, toutes deux généralisables. L'analyseur renvoie désormais un 0 explicite en cas d'échec, jamais une valeur de pile non initialisée. Et l'appelant ne fait plus confiance à /N sans calcul préalable : la région d'en-tête ObjStm avant /First stocke une paire d'entiers — numéro d'objet et décalage — pour chaque objet compressé, et chaque paire occupe au moins quatre octets avec ses séparateurs. Tout /N supérieur à FirstVal div 4 + 1 est donc physiquement impossible pour la taille d'en-tête déclarée et est rejeté avant toute allocation. La vérification coûte une comparaison et s'appuie sur des données déjà disponibles. C'est le principe à retenir : un plafond prouvé par le fichier lui-même, et non une constante arbitraire
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
Deux autres plafonds complètent le périmètre défensif de notre lecteur, tous deux introduits dans la version v2.12.0. Le lecteur de flux rejette tout /Length supérieur à la taille totale du fichier avant d'allouer le tampon de résultat — un flux ne pouvant être plus grand que le conteneur dans lequel il réside, cette vérification est exempte de faux positifs. De plus, le processus de décompression limite la sortie décompressée à 256 Mo, ce qui neutralise le piège classique de la bombe zlib où quelques kilo-octets de données compressées s'étendent indéfiniment. Ce plafond est généreux pour tout flux PDF réel tout en rendant le pire des cas gérable. Le principe est constant : chaque taille déclarée par un fichier est une simple prétention, et l'analyseur vérifie chaque prétention par rapport à des données mesurables avant d'y allouer de la mémoire. La même posture d'audit s'applique un niveau plus bas, au niveau de la liaison native, ce qui est traité dans la sécurisation de l'ABI PDFium et de la sécurité mémoire dans Delphi
Où la règle du jeton complet ne suffit pas
Des limites honnêtes afin de ne pas surévaluer la routine ci-dessus. La correspondance de jeton complet résout l'identification des clés, mais une recherche brute d'octets sur une plage de dictionnaire ne peut toujours pas déterminer si une correspondance se situe à l'intérieur d'un dictionnaire imbriqué, d'une chaîne littérale ou d'un commentaire — FindDictKey sur un objet de page peut correspondre à une clé au sein de son sous-dictionnaire /Resources si vous lui transmettez une plage trop large. Notre lecteur limite d'abord la plage de recherche au corps d'un seul objet et traite les contextes de chaînes et de commentaires comme des éléments distincts. La sécurité des sous-chaînes est un échelon d'une échelle, pas l'échelle entière : la cohérence des références croisées relève de sa propre discipline, traitée dans la validation des flux d'objets et de xref, et le catalogue plus large des menaces pour les documents tiers est présenté dans l'audit des risques de sécurité PDF
Si vous maintenez un lecteur de dictionnaire écrit à la main en Delphi ou Lazarus, les actions à mener suite à cet incident sont simples. Recherchez tous les Pos('/ dans la base de code et redirigez-les vers une fonction d'aide à jeton complet. Dressez la liste des familles de préfixes auxquelles vos clés participent — /Length, /Encrypt, /N, /Type face à /Type1 apparaissent toutes dans les fichiers réels. Ensuite, examinez chaque entier transmis à SetLength, GetMem ou à une boucle de copie et déterminez ce qui le limite : la taille du fichier, un plafond dérivé de l'en-tête, ou rien du tout. Cette couche d'analyse constitue la base de notre composant PDFium Component, où le lecteur d'octets et le moteur de rendu se cross-valident sur chaque document traité