Article technique

Renforcement de la sécurité d'un signataire PDF Delphi contre les PKCS#12 malveillants

Lorsque vous signez un PDF, vous considérez généralement la clé de signature comme quelque chose que vous contrôlez. Elle se trouve dans un fichier .pfx que vous avez généré, protégé par un mot de passe que vous avez choisi. Le code qui lit ce fichier ressemble à de la simple plomberie, pas à une frontière de sécurité. Cette intuition est fausse à partir du moment où le certificat cesse d'être le vôtre. Un outil de bureau qui permet à un utilisateur de choisir n'importe quel .pfx, un serveur qui accepte une donnée d'identification téléchargée, un signataire par lots (batch) alimenté en certificats via le réseau : tous transmettent des octets influencés par un attaquant à un analyseur avant même qu'un seul octet de signature ne soit produit. Un lecteur PKCS#12 constitue une surface d'attaque, au même titre qu'un décodeur d'image ou un chargeur de polices

Cet article détaille deux véritables défauts qui résidaient dans ce lecteur, tous deux dans le chemin qui importe un identifiant de signature. Aucun des deux n'est exotique. Tous deux proviennent de la même cause première qui affecte presque tous les analyseurs binaires écrits dans un langage avec des entiers de largeur fixe : une longueur ou un compte provenant du fichier bénéficie de confiance un peu plus loin qu'il ne le devrait. L'un conduit à une lecture hors limites (out-of-bounds read), l'autre à un processus qui se fige (hang) jusqu'à ce que vous le terminiez

Le parcours des octets

L'importation d'un .pfx pour signer un document n'est pas une seule opération, c'est un court pipeline, et chaque étape analyse quelque chose qu'un attaquant pourrait avoir écrit. Le conteneur est une structure PKCS#12 telle que définie dans la RFC 7292, une imbrication de sacs AuthenticatedSafe enveloppés autour d'une enveloppe chiffrée (shroud) qui contient la clé privée. La lire 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 se trouve dans HPDFPFX. Chaque balise, longueur et valeur qu'elle touche est décodée par le lecteur ASN.1 dans HPDFASN1. La dérivation de clé et le déchiffrement PBES2 se situent dans HPDFCrypt à côté de PBKDF2HMACSHA256. Lorsque la clé est récupérée, HPDFRSA et le constructeur CMS SignedData dans HPDFCMS la transforment en la signature détachée intégrée dans le PDF. Le point d'entrée public qui pilote l'ensemble de la chaîne est un seul appel

// Pilote le pipeline complet : charge le PDF de remplacement, analyse le PFX,
// dérive la clé, construit le SignedData CMS, écrit la sortie signée.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature intégrée
else
  // la signature ne s'est pas terminée
;

Chaque octet de signer.pfx traverse HPDFASN1 et HPDFPFX avant que la moindre opération de cryptographie n'ait lieu. Si ces deux unités ne sont pas prudentes quant à ce que le fichier prétend, la cryptographie en aval n'a jamais l'occasion d'entrer en jeu

Défaut un : une longueur ASN.1 qui dépasse la protection (wraparound)

ASN.1 en DER et BER encode chaque élément sous la forme d'une balise, d'une longueur et de ce même nombre d'octets de contenu. La longueur est le champ auquel vous devez faire confiance mais que vous devez vérifier, car il indique à l'analyseur jusqu'où lire, et il a été écrit par quiconque a produit le fichier. L'X.690 §8.1.3 définit deux encodages. La forme courte regroupe une longueur de 0 à 127 dans un seul octet. La forme longue, utilisée pour tout ce qui est plus grand, consacre un octet de tête dont les sept bits de poids faible donnent le nombre d'octets de longueur qui suivent, puis ce nombre d'octets gros-boutistes (big-endian) transporte la valeur réelle. Quatre octets de longueur peuvent donc déclarer une taille de contenu approchant les quatre gigaoctets

Après avoir décodé une telle valeur, l'analyseur doit vérifier que le contenu tient effectivement à l'intérieur du tampon (buffer) avant de lui faire confiance. La vérification naturelle consiste à s'assurer que la position actuelle plus 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 tous conservés dans des entiers signés de 32 bits, cette protection est défaillante :

// Le piège : l'arithmétique signée 32 bits. Avec ContentLen proche de MaxInt,
// Pos + ContentLen déborde vers une valeur NÉGATIVE, donc la comparaison
// est fausse et une longueur falsifiée d'environ 2 Go passe directement.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

Le problème est l'addition, pas la comparaison. Lorsque ContentLen est proche de MaxInt (2147483647), Pos + ContentLen déborde de la plage 32 bits signée et revient cycliquement (wraps around) à un nombre négatif. Une somme négative n'est jamais supérieure à Total, la protection signale donc que tout va bien et permet à l'analyseur de poursuivre avec une longueur de contenu d'environ deux gigaoctets que le tampon ne contient pas. Ce qui se passe ensuite constitue les dégâts : le lecteur alloue un tampon pour cette longueur annoncée et y copie des données, un SetLength suivi d'un Move lisant depuis la source. La source n'a plus que quelques centaines d'octets restants, la copie va donc lire bien au-delà de la fin de l'entrée, une lecture hors limites (out-of-bounds read) qui, au mieux plante, et au pire laisse fuiter la mémoire de processus adjacente dans l'analyse

La seule protection correcte élargit la somme intermédiaire avant la comparaison, afin que l'addition ne puisse pas faire déborder le type dans lequel elle est calculée. Le correctif promeut les deux opérandes en Int64 :

// Correct : les deux opérandes sont élargis en Int64 avant l'addition, afin que la somme
// ne puisse pas faire de dépassement. Une longueur falsifiée de 2 Go échoue désormais à la vérification des limites.
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 peut contenir la somme de deux valeurs de 32 bits sans perte, la comparaison voit donc le vrai nombre et rejette la longueur falsifiée. La vérification distincte de non-négativité sur ContentLen ferme le cas correspondant où une valeur décodée s'avère négative par elle-même. Dans HotPDF, cette protection vit dans HPDFASN1ParseNode, la fonction qui produit le nœud sur lequel chaque autre assistant s'appuie. Étant donné que HPDFASN1Content dimensionne son SetLength et son Move directement à partir de la longueur du contenu du nœud, un nœud ayant passé une mauvaise protection aurait empoisonné chaque lecture effectuée à partir de lui. Corriger la limite au point de décodage est ce qui rend les assistants situés au-dessus sûrs

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

Le deuxième défaut n'est pas une erreur de mémoire, c'est le fichier qui dit à votre processeur avec quelle intensité il doit travailler. PKCS#12 protège son matériel 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 chiffrement, ici AES-256-CBC. PBKDF2 prend un nombre d'itérations, et ce compte est un paramètre transporté dans le fichier. Son seul but est d'être lent : plus d'itérations signifie que chaque tentative de deviner le mot de passe coûte plus cher, ce qui est efficace contre un attaquant hors ligne. La RFC 8018 §4.2 est explicite sur le fait qu'un compte plus élevé est meilleur pour la sécurité, et ne fixe délibérément aucun plafond

Cette ouverture est très bien lorsque vous avez généré le fichier. C'est une arme lorsque l'attaquant l'a fait. Le nombre d'itérations est un facteur de travail contrôlé par l'attaquant, et un facteur de travail contrôlé par l'attaquant constitue un déni de service de complexité algorithmique. Un .pfx falsifié peut encoder un nombre d'itérations se comptant en milliards ; l'analyseur le lit consciencieusement et appelle PBKDF2 pour autant de cycles de HMAC-SHA-256, et le processus disparaît dans une boucle qui ne reviendra pas avant des minutes ou des heures pour un fichier fourni. Sur un serveur de signature qui gère un identifiant par requête, un seul téléchargement malveillant bloque un processus de travail (worker)

Le nombre (count) aggrave le dépassement cyclique (wraparound) avant de faire tourner le processeur à vide. La valeur d'itération vit dans le fichier sous la forme d'un INTEGER ASN.1, qui n'a pas de largeur fixe, tandis que le champ que PBKDF2 consomme au final est un Integer de 32 bits. Décodez l'INTEGER directement dans ce champ et une grande valeur est tronquée, et une valeur conçue pour atterrir sur le bit de signe revient négative ou sous la forme d'un petit nombre sans rapport, de sorte que même la taille du travail n'est plus ce que le fichier semblait demander. Le correctif lit la valeur en pleine largeur et la limite avant de la réduire :

// Lisez d'abord le nombre d'itérations sous forme de Int64, puis limitez-le à une plage saine
// AVANT qu'il ne soit réduit dans le champ Iterations 32 bits que PBKDF2 utilise.
LIter := HPDFASN1ToInteger(Data, Node);          // renvoie un 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);                    // sûr : déjà limité

Lire dans un Int64 signifie que la valeur décodée est la valeur réelle, et non un fantôme tronqué de celle-ci. La limite inférieure rejette les comptes nuls et négatifs, qui n'ont aucun sens pour une dérivation de clé. La limite supérieure, cent millions, se situe bien au-dessus de tout fichier PKCS#12 légitime, qui utilise aujourd'hui des dizaines ou quelques centaines de milliers d'itérations, tout en plafonnant le pire des cas à une quantité de travail limitée et qui peut être surmontée. Ce n'est qu'une fois que la valeur a franchi cette bande qu'elle est rétrécie au champ de 32 bits, de sorte que la troncature ne peut plus surprendre personne. Dans HotPDF, ce serrage (clamp) se trouve dans ParsePBES2Params, où les paramètres PBKDF2 sont décodés en route vers PBKDF2HMACSHA256

Pourquoi les deux correctifs sont le même correctif

Les deux défauts ont l'air différents, l'un un dépassement de tampon et l'autre un processus figé, mais il s'agit de la même erreur. Dans chaque cas, un nombre provenant d'un fichier non fiable a été transporté dans un type de largeur fixe une étape trop tôt, avant d'avoir été confronté à la réalité. La longueur a été ajoutée en 32 bits avant le test de limites ; le nombre d'itérations a été réduit à 32 bits avant le test de plage. Tous deux cèdent à la même discipline : décoder en pleine largeur, vérifier par rapport à la limite réelle, et ensuite seulement réduire. Le Int64 intermédiaire n'est pas un choix de style, c'est la seule largeur dans laquelle la protection peut voir la valeur que l'attaquant a réellement écrite. Une limite qui déborde n'est pas une limite, et un nombre sans plafond n'est pas un paramètre, c'est un accélérateur (throttle) distant sur votre propre CPU

Conseils pratiques pour un pipeline de signature

La leçon immédiate est de valider les entrées de certificats non fiables comme vous valideriez n'importe quel téléchargement non fiable. Plafonnez la taille d'un .pfx que vous acceptez, puisqu'un fichier légitime fait quelques kilo-octets, pas des mégaoctets. Considérez un échec d'analyse comme une entrée rejetée de routine, et non comme une erreur justifiant une trace de la pile (stack trace) pour l'utilisateur. Si vous signez sur un serveur, exécutez l'importation là où un processus de travail bloqué ne peut pas entraîner l'arrêt du service avec lui, et placez un délai d'attente (timeout) autour de l'opération de sorte qu'un fichier d'un coût inattendu soit limité par le temps réel (wall-clock) ainsi que par le plafond d'itérations

La leçon plus large va au-delà des certificats. Le durcissement de l'analyseur n'est pas un audit ponctuel d'une seule unité, c'est une propriété de chaque endroit où votre bibliothèque lit des octets qu'elle n'a pas écrits. Une bibliothèque PDF analyse un grand nombre d'éléments provenant de sources non fiables : des polices intégrées dans un document, des images dans une demi-douzaine de codecs, des filtres de flux et, sur le chemin de la signature, des certificats. Chacun d'entre eux constitue une surface d'attaque, et chacun mérite la même méfiance à l'égard de chaque longueur et de chaque nombre (count). HotPDF construit le chemin d'importation et de signature sur les unités HPDFASN1, HPDFPFX, HPDFCrypt et HPDFCMS durcies décrites ici, afin que l'identifiant que vous lui transmettez, d'où qu'il vienne, soit analysé de manière défensive avant même de lui faire confiance

Le flux de travail de signature que ces vérifications protègent est couvert de bout en bout dans notre présentation pas à pas des signatures numériques PAdES dans Delphi, et la même posture 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 AES-256 et la sécurité. Tout cela est livré dans le cadre du Composant HotPDF pour Delphi et C++Builder, à côté des API de chargement, d'édition, de chiffrement et de signature abordées ailleurs sur ce blog