Technical Article

Sécurisation d'un signataire PDF Delphi contre les fichiers PKCS#12 malveillants

Lorsque vous signez un PDF, vous considérez généralement la clé de signature comme un élément que vous contrôlez. Elle réside dans un fichier .pfx que vous avez généré, protégé par un mot de passe de votre choix. Le code qui lit ce fichier ressemble à de la plomberie interne, pas à une frontière de sécurité. Cette intuition devient fausse dès lors que le certificat ne vous appartient plus. Un outil de bureau qui permet à un utilisateur de choisir n'importe quel .pfx, un serveur qui accepte un identifiant téléversé ou un système de signature par lots alimenté en certificats via le réseau transmettent tous des octets influencés par un attaquant à un analyseur avant même qu'un seul octet de signature ne soit généré. Un lecteur PKCS#12 est une surface d'attaque, au même titre qu'un décodeur d'images ou un chargeur de polices.

Cet article présente deux défauts réels qui résidaient dans ce lecteur, tous deux situés sur le chemin d'importation d'un identifiant de signature. Aucun d'eux n'est exotique. Tous deux découlent de la même cause racine qui affecte presque tous les analyseurs binaires écrits dans des langages à entiers de largeur fixe : une longueur ou un compteur provenant du fichier bénéficie d'une confiance excessive. L'un conduit à une lecture hors limites, l'autre à un processus qui se fige jusqu'à son arrêt forcé.

Le parcours des octets

L'importation d'un .pfx pour signer un document n'est pas une opération unique, c'est un court pipeline dont chaque étape analyse des éléments potentiellement forgés par un attaquant. Le conteneur est une structure PKCS#12 telle que définie par la RFC 7292, un ensemble de sacs AuthenticatedSafe enveloppés autour d'une enveloppe chiffrée qui contient la clé privée. Sa lecture implique de parcourir de l'ASN.1, de dériver une clé à partir du mot de passe, de déchiffrer, puis de transmettre la clé RSA récupérée au code qui construit la signature.

Dans HotPDF, ces étapes correspondent à des unités distinctes. La logique du conteneur PKCS#12 réside dans HPDFPFX. Chaque étiquette (tag), longueur et valeur qu'elle traite est décodée par le lecteur ASN.1 dans HPDFASN1. La dérivation de clé et le déchiffrement PBES2 se trouvent dans HPDFCrypt aux côtés de PBKDF2HMACSHA256. Une fois la clé récupérée, HPDFRSA et le constructeur CMS SignedData dans HPDFCMS la transforment en la signature détachée incorporée dans le PDF. Le point d'entrée public qui pilote l'ensemble de la chaîne s'effectue en un seul appel.

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

Chaque octet de signer.pfx transite par HPDFASN1 et HPDFPFX avant qu'aucune opération cryptographique ne se produise. Si ces deux unités ne valident pas rigoureusement les affirmations du fichier, la cryptographie en aval n'aura jamais l'occasion d'intervenir.

Défaut un : une longueur ASN.1 qui dépasse la barrière par débordement

L'ASN.1 en DER et BER encode chaque élément sous la forme d'un tag, d'une longueur et d'autant d'octets de contenu. La longueur est le champ auquel vous devez appliquer le principe du "faire confiance mais vérifier", car elle indique à l'analyseur jusqu'où lire, et elle a été écrite par le créateur du fichier. La norme X.690 §8.1.3 définit deux encodages. La forme courte rassemble une longueur de 0 à 127 dans un seul octet. La forme longue, utilisée pour tout ce qui est plus grand, emploie un premier octet dont les sept bits de poids faible indiquent le nombre d'octets de longueur qui suivent, puis ces derniers transportent la valeur réelle en big-endian. Quatre octets de longueur peuvent ainsi déclarer une taille de contenu proche de quatre gigaoctets.

Après de décoder une telle valeur, l'analyseur doit vérifier que le contenu tient réellement dans le tampon de données avant de s'y fier. La vérification naturelle consiste à s'assurer que la position actuelle augmentée de la longueur du contenu ne dépasse pas la fin des données. Écrite de manière évidente, avec la position, la longueur du contenu et le total stockés dans des entiers 32 bits signés, cette barrière est inefficace :

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

Le problème réside dans l'addition, pas dans la comparaison. Lorsque ContentLen est proche de MaxInt (2147483647), Pos + ContentLen dépasse la plage signée de 32 bits et boucle sur un nombre négatif. Une somme négative n'étant jamais supérieure à Total, la barrière indique que tout est correct et laisse l'analyseur continuer avec une longueur de contenu de près de deux gigaoctets que le tampon ne contient pas. C'est à ce moment que se produit le sinistre : le lecteur alloue un tampon pour cette longueur revendiquée et y effectue une copie, un SetLength suivi d'un Move lisant depuis la source. La source ne contenant plus que quelques centaines d'octets, la copie lit bien au-delà de la fin de l'entrée. Cette lecture hors limites provoque au mieux un plantage, au pire une fuite de la mémoire adjacente du processus dans le flux d'analyse.

La seule barrière correcte élargit la somme intermédiaire avant la comparaison, afin que l'addition ne puisse pas déborder du type dans lequel elle est calculée. La correction promeut les deux opérandes en Int64 :

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Un Int64 conserve sans perte la somme de deux valeurs 32 bits, de sorte que la comparaison évalue le nombre réel et rejette la longueur falsifiée. La vérification distincte de non-négativité sur ContentLen couvre le cas où une valeur décodée s'avère négative d'elle-même. Dans HotPDF, cette barrière réside dans HPDFASN1ParseNode, la fonction qui produit le nœud sur lequel s'appuient tous les autres assistants. Étant donné que HPDFASN1Content dimensionne son SetLength et son Move directement à partir de la longueur du contenu du nœud, un nœud ayant franchi une mauvaise barrière aurait corrompu chaque lecture ultérieure. Corriger la limite au moment du décodage est ce qui sécurise les assistants de niveau supérieur.

Défaut deux : un nombre d'itérations PBKDF2 utilisé comme une arme

Le second défaut n'est pas une erreur de mémoire, mais le fichier qui impose au processeur sa charge de travail. PKCS#12 protège ses éléments de clé avec PBES2, le schéma basé sur mot de passe de PKCS#5 spécifié dans la RFC 8018. PBES2 exécute une fonction de dérivation de clé, ici PBKDF2 avec HMAC-SHA-256, puis un algorithme de chiffrement, ici AES-256-CBC. PBKDF2 prend un nombre d'itérations, et ce nombre est un paramètre fourni par le fichier. Son but même est d'être lent : plus d'itérations signifie que chaque tentative de mot de passe coûte plus cher, ce qui est efficace contre un attaquant hors ligne. La RFC 8018 §4.2 précise que plus le nombre est élevé, mieux c'est pour la sécurité, et elle ne fixe délibérément aucune limite.

Cette flexibilité convient lorsque vous générez le fichier. Elle devient une arme lorsque c'est un attaquant qui le fait. Le nombre d'itérations est un facteur de travail contrôlé par l'attaquant, ce qui constitue un déni de service par complexité algorithmique. Un fichier .pfx falsifié peut encoder un nombre d'itérations se chiffrant en milliards ; l'analyseur le lit docilement et appelle PBKDF2 pour autant de cycles HMAC-SHA-256, et le processus s'enfonce dans une boucle qui ne se terminera pas avant des minutes ou des heures pour un seul fichier fourni. Sur un serveur de signature qui traite un identifiant par requête, un seul téléversement malveillant bloque un processus de travail.

Le compteur aggrave le débordement avant même de faire tourner le processeur. La valeur d'itération figure dans le fichier sous la forme d'un INTEGER ASN.1 qui n'a pas de largeur fixe, alors que le champ consommé au final par PBKDF2 est un Integer de 32 bits. Si l'on décode cet INTEGER directement dans ce champ, une grande valeur sera tronquée, et une valeur conçue pour activer le bit de signe renverra un nombre négatif ou une petite valeur sans rapport. Ainsi, l'ampleur du travail ne correspond même plus à ce que le fichier semblait demander. La correction lit la valeur à sa pleine largeur et la délimite avant de la réduire :

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

Lire la valeur dans un Int64 garantit que la valeur décodée est la valeur réelle, et non un reflet tronqué. La limite inférieure rejette les compteurs nuls ou négatifs, ce qui n'a aucun sens pour une dérivation de clé. La limite supérieure, fixée à cent millions, se situe bien au-dessus de tout fichier PKCS#12 légitime (qui utilise actuellement quelques dizaines ou centaines de milliers d'itérations), tout en limitant le pire des cas à une quantité de travail acceptable et gérable. C'est seulement après avoir validé cette plage que la valeur est convertie dans le champ 32 bits, empêchant ainsi toute troncature surprise. Dans HotPDF, ce bridage s'effectue dans ParsePBES2Params, où les paramètres PBKDF2 sont décodés avant d'être transmis à PBKDF2HMACSHA256.

Pourquoi ces deux corrections partagent le même principe

Ces deux défauts semblent différents (l'un étant un dépassement de tampon et l'autre un processus figé), mais ils résultent de la même erreur. Dans chaque cas, un nombre issu d'un fichier non fiable a été injecté dans un type à largeur fixe une étape trop tôt, avant d'être confronté à la réalité. La longueur a été additionnée en 32 bits avant le test des limites ; le nombre d'itérations a été réduit à 32 bits avant le test de plage. Tous deux se plient à la même discipline : décoder à pleine largeur, vérifier par rapport à la limite réelle, puis seulement après, réduire. L'utilisation d'un type intermédiaire Int64 n'est pas un choix de style, c'est la seule largeur dans laquelle la barrière de sécurité peut voir la valeur réellement écrite par l'attaquant. Une limite qui déborde n'est plus une limite, et un compteur sans plafond n'est plus un paramètre, c'est un accélérateur à distance sur votre propre processeur.

Conseils pratiques pour un pipeline de signature

La leçon immédiate est de valider les certificats non fiables comme vous le feriez pour tout téléversement suspect. Limitez la taille des fichiers .pfx acceptés, un certificat légitime se comptant en kilo-octets et non en méga-octets. Traitez un échec d'analyse comme un rejet d'entrée classique, et non comme une erreur méritant une trace de pile d'appels présentée à l'utilisateur. Si vous signez sur un serveur, exécutez l'importation dans un conteneur où un processus bloqué ne peut pas paralyser tout le service, et appliquez un délai d'expiration (timeout) à l'opération afin qu'un fichier anormalement coûteux soit bridé par le temps d'exécution réel en plus du plafond d'itérations.

La leçon plus générale va bien au-delà des certificats. La sécurisation des analyseurs n'est pas un audit ponctuel d'une seule unité, c'est une propriété requise partout où votre bibliothèque lit des octets qu'elle n'a pas écrits. Une bibliothèque PDF analyse de nombreux éléments provenant de sources non fiables : polices incorporées dans un document, images dans une demi-douzaine de codecs, filtres de flux et, sur le chemin de signature, certificats. Chacun d'eux constitue une surface d'attaque et mérite la même méfiance à l'égard de chaque longueur et de chaque compteur. HotPDF construit le chemin d'importation et de signature sur les unités sécurisées HPDFASN1, HPDFPFX, HPDFCrypt et HPDFCMS présentées ici, de sorte que l'identifiant que vous lui transmettez, d'où qu'il vienne, soit analysé de manière défensive avant de faire l'objet de la moindre confiance.

Le flux de signature protégé par ces vérifications est détaillé de bout en bout dans notre guide sur les signatures numériques PAdES dans Delphi, et la même approche défensive appliquée au chiffrement de documents, y compris le chemin de clé AES-256 qui partage cette base de code, est décrite dans l'article sur le chiffrement et la sécurité AES-256. Tout cela est fourni avec le composant HotPDF pour Delphi et C++Builder, aux côtés des API de chargement, d'édition, de chiffrement et de signature présentées ailleurs sur ce blog.