Blog / CVE expliquées

Confusion d’algorithme dans fast-jwt : corrigée à la troisième tentative

fast-jwt a corrigé trois fois la confusion d’algorithme RSA vers HS256. CVE-2026-107722 montre pourquoi les deux premiers correctifs ont fermé une entrée plutôt que la règle.

Études doctorales en sécurité de l’information achevéesSoutenance universitaire réussie · Soutenance finale de thèse à venir

Le même bogue, corrigé trois fois

Aucun mot de passe. Un accès administrateur quand même. Voilà ce qu’achète un jeton forgé face au vérificateur RS256 de fast-jwt : un bogue corrigé trois fois avant que le correctif ne tienne vraiment. Le 8 octobre 2026, la GitHub Advisory Database a publié CVE-2026-107722, le rapport derrière le correctif qui a enfin tenu. [1] Lisez le titre lentement : « Incomplete patch of CVE-2026-34950 », soit « correctif incomplet de CVE-2026-34950 ». Lisez maintenant le titre de CVE-2026-34950 elle-même, datant d’avril : « Incomplete fix for CVE-2023-48223 », soit « correction incomplète de CVE-2023-48223 ». [3]

Trois avis, une bibliothèque, une seule classe de vulnérabilité. Chaque correctif fermait exactement l’entrée que le chercheur précédent avait démontrée, et chaque fois, quelqu’un arrivait avec une entrée légèrement différente et passait tout droit.

Cette chaîne est le vrai sujet. La vulnérabilité elle-même est un cas d’école de confusion d’algorithme, et si c’est tout ce que vous en retenez, vous mettrez à jour vers la 6.3.0 et passerez à autre chose. Ce qui mérite votre attention, c’est pourquoi deux correctifs compétents ont échoué et pas le troisième, car cette différence s’applique directement à vos propres correctifs dès lundi.

Je n’ai rien reproduit de tout cela moi-même. Ce qui suit provient de l’avis, de l’avis précédent et du diff qui a corrigé les deux. [1][2][3]

CVE-2023-48223   the original
      v
CVE-2026-34950   "incomplete fix"   9.1
      v                (added trim)
CVE-2026-107722  "incomplete patch" 9.8

Le serveur fait confiance à la clé. L’attaquant contrôle la chaîne de caractères

La confusion d’algorithme JWT, en une phrase : le jeton indique lui-même au serveur comment le vérifier. Si une application signe avec RS256 et que le vérificateur peut être amené à utiliser HS256 à la place, la clé publique RSA cesse d’être une clé publique et devient un secret partagé. Elle est publique. L’attaquant la possède. Il signe un jeton avec elle, le serveur calcule le même HMAC, et la signature correspond.

Ce qui rend fast-jwt intéressant, c’est l’endroit précis où cette décision est prise. La bibliothèque détermine si votre clé est asymétrique en l’examinant comme une chaîne de caractères : si elle ressemble à un bloc PEM, elle la traite comme du RSA ; sinon, comme un secret HMAC brut. [1]

C’est une approche raisonnable, et presque toutes les bibliothèques JWT font quelque chose de similaire. Mais cela signifie aussi que la classification dépend d’une correspondance textuelle, et la correspondance textuelle a des cas limites que la cryptographie n’a pas.

Pourquoi trim() ressemblait au bon correctif

En avril, CVE-2026-34950 signalait qu’un espace en tête mettait en échec la vérification PEM. [3] Le comparateur était une expression régulière ancrée au début de la chaîne, de sorte qu’une clé commençant par un espace ne correspondait pas, était classée comme secret brut, et la voie HMAC s’ouvrait.

Le correctif de la 6.2.0 a été un appel à trim() avant la vérification. [1][2] Minimal, manifestement correct pour l’entrée signalée, livré avec un test de non-régression. Je parierais que la plupart des relecteurs l’auraient approuvé en moins d’une minute, et j’aurais probablement été l’un d’eux.

Mais l’entrée signalée n’a jamais été le vrai bogue. Le bogue, c’était l’ancrage. Une expression régulière commençant par un accent circonflexe code une hypothèse : l’en-tête PEM se trouve en position zéro. trim() ne supprime pas cette hypothèse, elle la rend simplement vraie pour une seule catégorie de caractères, car String.prototype.trim ne supprime que ce que la spécification ECMAScript appelle des espaces blancs. [1]

Tout le reste fonctionne encore. Les tests de non-régression livrés avec le véritable correctif énumèrent ce que « tout le reste » signifie en pratique : un octet NUL, un caractère d’échappement, DEL, des caractères de largeur nulle, une ligne de commentaire, une enveloppe HTTP. [2] N’importe lequel d’entre eux devant l’en-tête PEM, et la clé redevient un secret brut.

L’avis le formule mieux que je ne pourrais le faire : le correctif était textuellement étroit, limité aux espaces blancs, plutôt que de traiter la classe entière. [1]

// 6.1.0 and earlier
/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/

// 6.2.0: trim() first, same anchor
trimmedKey.match(publicKeyPemMatcher)

// a leading NUL still walks past it

Le troisième correctif a changé la règle, pas l’entrée

Regardez ce que la 6.3.0 fait réellement différemment, car ce n’est pas une liste de suppression plus longue. [2]

Elle abandonne l’ancrage. Un nouveau comparateur trouve un en-tête PEM n’importe où dans la chaîne, et la clé est extraite à partir de là. Une clé n’est traitée comme un secret HMAC brut que si elle ne contient aucun en-tête PEM du tout, et dès qu’un en-tête est présent, la clé ne peut plus jamais retomber sur HMAC. La vérification X.509 passe de includes à startsWith. Une clé qui comporte des marqueurs PEM mais ne se classe pas proprement lève désormais une erreur au lieu de retomber silencieusement dans la branche HMAC.

Le message de commit formule la nouvelle règle en une ligne, et c’est le genre de phrase qui mérite d’être reprise dans votre propre base de code : dès qu’un en-tête est présent, la clé doit être classée comme matériel de clé asymétrique et ne peut plus jamais retomber sur HMAC. [2]

Les deux premiers correctifs mettaient une entrée sur liste noire. Le troisième a mis une forme sur liste blanche. C’est toute la différence, et c’est pourquoi celui-ci ferme la classe entière au lieu d’une instance de plus.

// 6.3.0 - find it anywhere
/-----BEGIN [A-Z0-9 ]+?-----/

const { pem, isRawSecret } =
  locatePem(trimmedKey)

if (isRawSecret) return hsAlgorithms
// unless algorithms explicitly pins HS256 - that still skips this check

Figer l’algorithme aide. Figer le mauvais, non

Voici l’endroit où je parierais que des gens se font piéger. L’avis liste les configurations vulnérables : pas de liste blanche algorithms, une fonction de rappel asynchrone pour la clé, ou algorithms réglé sur HS256. La configuration listée comme protégée est algorithms réglé sur RS256. [1]

Figer l’algorithme est le conseil standard contre la confusion d’algorithme, et c’est un bon conseil. Mais une équipe qui parcourt un avis en diagonale, voit HS256 dans la description de l’attaque et fige HS256 a figé le vérificateur sur le choix de l’attaquant. La liste blanche doit nommer l’algorithme avec lequel vous signez réellement, pas celui dont vous avez lu le nom dans l’analyse.

Quand je relis du code d’authentification, c’est la forme que je cherche en premier : un contrôle défensif configuré à partir du rapport d’incident plutôt qu’à partir de la conception propre du système. Il passe la relecture parce qu’il s’agit visiblement d’un contrôle, et il pointe dans la mauvaise direction.

Votre service est-il réellement exposé ?

Plus restreint que ce que suggère un score de 9,8, et trois éléments doivent s’aligner. [1]

Premièrement, la version résolue de fast-jwt se situe entre 6.2.0 et 6.2.4 pour cette CVE précise ; la 6.1.0 et antérieures ne sont pas sûres non plus, mais exposées plutôt au deuxième maillon de la chaîne, CVE-2026-34950. Vérifiez ce que npm a réellement installé, pas ce que demande package.json, et vérifiez la dépendance transitive : fast-jwt est le backend JWT par défaut de @fastify/jwt, donc de nombreuses équipes l’utilisent sans l’avoir choisi elles-mêmes. [1]

Deuxièmement, le vérificateur est configuré de l’une des façons vulnérables ci-dessus. Troisièmement — et c’est la seule chose que personne ne peut vérifier à votre place — votre clé configurée porte un caractère autre qu’un espace blanc avant son en-tête PEM. Une clé propre n’est pas du tout concernée.

Cette dernière condition est une propriété de la façon dont la clé parvient au processus, pas de votre code. Lue depuis un fichier, collée dans une variable d’environnement, récupérée depuis un gestionnaire de secrets, concaténée avec une étiquette par un script de déploiement. Je n’ai aucune donnée sur la fréquence de ce phénomène dans la nature, et l’avis non plus : traitez-le donc comme un endroit où regarder, pas comme une probabilité.

Ce qu’il faut vérifier aujourd’hui

Trois choses, dans l’ordre où je les ferais.

Mettez à niveau vers la 6.3.0 et confirmez ensuite la version résolue. Fixez ensuite une liste blanche explicite d’algorithms nommant l’algorithme asymétrique que votre service utilise réellement. Puis, et c’est celle qui survit à cette CVE précise, vérifiez la forme de votre clé de vérification au démarrage plutôt que de faire confiance à ce que vous fournit le fichier ou la variable d’environnement.

Cette troisième vérification coûte une ligne, et elle aurait rendu les deux dernières de ces CVE inexploitables sur votre service, y compris la suivante. (La première, CVE-2023-48223, avait une forme différente : une clé valide que l’ancien comparateur ne reconnaissait pas, et non un préfixe parasite devant elle ; cette vérification précise ne l’aurait donc pas détectée.)

C’est là la vraie leçon. Une bibliothèque qui a corrigé trois fois la même classe reviendra probablement. Un chargeur de clé qui refuse tout ce qui n’est pas exactement un bloc PEM, lui, s’en moquera.

# the version that actually loads
npm ls fast-jwt

# pin the algorithm you really use
{ algorithms: ['RS256'] }

# assert the key's shape at boot
key.startsWith('-----BEGIN')

Corrigez la règle, pas le rapport

Chacun de ces correctifs a été réalisé par quelqu’un de compétent, en réponse à un rapport réel, avec un test. L’échec ne venait pas d’un manque de soin. Il venait du périmètre : le rapport décrivait une entrée, donc le correctif traitait l’entrée, et la règle qui rendait cette entrée dangereuse est restée exactement la même.

La question que je pose lors d’une relecture de correctif est délibérément agaçante : de quelle classe d’entrées ce rapport est-il un exemple, et le correctif ferme-t-il cette classe ? Si la réponse est non, vous n’avez pas empêché la prochaine CVE, vous lui avez fixé une date.

Cette question constitue l’essentiel d’une revue de code sécurité. Remonter d’un rapport jusqu’à la décision qui l’a rendu possible, puis vérifier si le correctif a déplacé cette décision ou seulement le symptôme, est un travail que je mène régulièrement sur du code client, et c’est exactement le genre de chose qu’aucun scanner ne signalera jamais, parce que le numéro de version corrigée paraît correct. Si vous voulez qu’un correctif soit relu avec le regard d’un attaquant avant que quelqu’un d’autre n’en trouve la faille, c’est la conversation à engager.

Sources

Besoin de vérifier ce point dans votre propre produit ?

Revue de code sécurité

Vous voulez apprendre à trouver vous-même ce type de vulnérabilité ?

Formation individuelle à la revue de code sécurisé
Tous les articles

08Contact

Vous livrez un produit qui traite des données d’utilisateurs ?

Envoyez-moi un bref descriptif. Je réponds sous trois jours avec des questions ou une première proposition, et le prix est fixé par écrit avant le démarrage.