Utiliser la boucle d’événements de Node.js pour les attaques temporelles
16 février 2016
0 minutes de lectureIl y a un peu plus de 3 ans, quelques amis et moi avons créé un groupe appelé pasten pour participer à la compétition Capture The Flag (CTF) du Chaos Computer Club. Il s’agit d’un CTF de type Jeopardy, où les équipes participantes doivent résoudre des défis liés à la sécurité dans différentes catégories : exploitation, rétro-ingénierie, Web, analyse forensique et cryptographie. Les CTF sont amusants et instructifs, et je vous recommande vivement d’y participer. C’est encore plus amusant quand on gagne, comme cette année !
Cette année, parmi les autres défis de la compétition, figurait une application Node.js vulnérable à une attaque temporelle intéressante qui exploitait la boucle d’événements propre à Node.js. Dans cet article, nous analysons le défi, expliquons les risques et montrons comment un attaquant peut mettre au jour et exploiter ce type de vulnérabilité. J’espère également que cela vous aidera à éviter des vulnérabilités similaires dans votre propre code.
Attaques temporelles
Avant d’examiner le défi, expliquons ce qu’est une attaque temporelle.
Imaginez un service qui, lorsqu’un utilisateur saisit un mot de passe incorrect associé à une adresse e-mail existante, répond : « La cinquième lettre de votre mot de passe est incorrecte, veuillez réessayer. » Absurde, n’est-ce pas ? Fournir une telle information permet à un attaquant de tester toutes les possibilités du mot de passe caractère par caractère, et rend l’intrusion triviale. Pourtant, c’est exactement ce qui se produit lorsqu’on utilise une comparaison de chaînes de caractères naïve pour vérifier des mots de passe ou des jetons d’authentification.
La comparaison native de chaînes de caractères, y compris avec l’opérateur == de JavaScript, parcourt généralement deux chaînes de même longueur, compare les caractères un à un et s’arrête dès qu’ils diffèrent. Ainsi, si je comparais foo à bar, la boucle ne ferait qu’une itération, tandis que la comparaison de foo avec fox porterait sur 3 caractères et prendrait plus de temps.
Exemple de fonction d’authentification vulnérable :
La comparaison d’un seul caractère est très rapide, mais elle prend suffisamment de temps pour être détectable. Des recherches montrent qu’un attaquant peut mesurer des événements avec une précision de 15 à 100 µs sur Internet, et de 100 ns sur un réseau local. Les attaquants peuvent exploiter ces techniques : même de petits délais peuvent ainsi révéler à l’utilisateur quel caractère était incorrect.
Ce type d’attaque est appelé attaque temporelle. Elle peut être menée chaque fois que les données saisies influent sur le temps de traitement. Pour l’empêcher, il faut que la comparaison des chaînes prenne le même temps, quel que soit le mot de passe saisi, par exemple en effectuant un xoring des deux mots de passe et en vérifiant si le résultat est égal à zéro.
Non vulnérable aux attaques temporelles :
Le défi : Sequence Hunt
Maintenant que nous avons ces informations, examinons le défi. Tout a commencé par un lien vers une page contenant le texte suivant :
Sequence HuntBienvenue dans ma chasse aux suites d’entiers. Vous devez calculer cinq nombres compris entre 0 et 100 en implémentant les algorithmes ci-dessous. Lorsque vous pensez avoir la bonne solution, envoyez-les ici en tant que paramètres GET.Vous pouvez également obtenir quelques informations vous concernant ici.Je m’attends à une forte charge, j’ai donc conçu cette application autour de boucles d’événements I/O asynchrones.Amusez-vous bien !
[MODIFICATION]Quelqu’un m’a envoyé ce lien. Comme chaque vérification prend un temps considérable, le serveur attend désormais que trois secondes se soient écoulées depuis la réception de la requête afin d’empêcher ces attaques.[/MODIFICATION]
AlgorithmesÀ PUBLIER
Le texte ici renvoie vers /check, où nous devons envoyer les bons paramètres pour obtenir le flag, et IOLoops renvoie vers /info, qui affiche ce qui suit :
vous êtes 37.120.106.140nombre de requêtes sur votre boucle d’événements I/O : 1
Que savons-nous jusqu’ici ?
Un algorithme inconnu prend 5 nombres compris entre 0 et 100 en entrée.
Pour obtenir le flag, nous devons fournir les bons nombres.
Le temps d’exécution de l’algorithme dépend probablement des données saisies, ce qui pourrait permettre une attaque temporelle.
Le serveur attend toujours au moins 3 secondes avant de renvoyer la réponse, afin de contrer les attaques temporelles.
Maintenant, nous devons trouver comment capturer le flag. Comme le défi s’intitule Sequence Hunt, j’utiliserai sequencehunt.com comme nom de domaine fictif dans mes exemples. N’envoyez aucune attaque vers ce domaine, qui n’a aucun lien avec le défi ! Vous pouvez lancer l’exemple d’application vulnérable que j’ai écrit. Son comportement devrait être similaire à celui de l’application du CTF.
Exploration
Une vérification rapide confirme qu’une requête check met bien environ 3 secondes à renvoyer une réponse, quelles que soient les valeurs fournies. Il est donc impossible de tester toutes les combinaisons par force brute. Il faudrait essayer 100^5 (10 milliards) de combinaisons, ce qui prendrait des milliers d’années…
En testant la page /info, nous remarquons que chaque requête /check incrémente le compteur de boucles I/O, tout comme la requête /info elle-même.
Envoyer des requêtes /check avec différents nombres aléatoires ne révèle aucune différence statistiquement significative dans les temps de réponse. L’algorithme semble s’exécuter en moins de 3 secondes ; toutes les requêtes sont donc simplement renvoyées après le délai minimum de 3 secondes.
Cependant, nous avons remarqué que l’envoi d’une requête à /info alors qu’une requête /check est en cours prend beaucoup plus de temps. Nous avons ainsi une piste à explorer liée au temps de réponse. Pour comprendre la suite, prenons un moment pour parler de la boucle d’événements de Node.js.
La boucle d’événements de Node.js
Conçu pour être évolutif, Node.js (et JavaScript en général) est un environnement asynchrone piloté par les événements. Lorsqu’un morceau de code doit déclencher une opération potentiellement bloquante, comme ouvrir un fichier ou écrire sur le réseau, il enregistre une fonction de rappel, déclenche l’opération concernée, puis se termine. Une fois l’opération terminée (par exemple, lorsque le fichier est ouvert), la boucle d’événements appelle la fonction de rappel.
Le modèle piloté par les événements est extrêmement évolutif, car les threads ne restent jamais à « attendre ». Il pose toutefois problème lorsqu’une fonction met longtemps à s’exécuter. Par défaut, le serveur Node.js n’exécute qu’un seul thread par cœur ; une fonction de longue durée occupe donc tout le cœur, tandis que les autres attendent leur tour dans la file. Dans un navigateur, c’est ce qui provoque le message d’erreur « le script met trop de temps à s’exécuter » que vous avez peut-être déjà vu. Sur un serveur, les requêtes restent simplement bloquées.
Pour mieux comprendre la boucle d’événements de Node.js, regardez l’excellente présentation de Philip Roberts à JSConf, ou lisez l’un de ces articles.
Résoudre l’énigme
Dans notre cas, la boucle d’événements nous a fourni les informations temporelles dont nous avions besoin. Tant que /check était en cours d’exécution, la boucle d’événements ne reprenait pas le contrôle, ce qui bloquait la requête /info. En revanche, la fonction setTimeout se contente d’enregistrer un événement planifié et de céder le contrôle, ce qui permet de traiter rapidement les requêtes /info. J’ai publié sur GitHub le code d’un serveur simple qui illustre ce phénomène, si vous souhaitez le tester en local.
Maintenant que nous avions un moyen d’obtenir des informations temporelles (autrement dit, un canal auxiliaire), il ne nous restait qu’à envoyer une requête /check puis, sans attendre la réponse, une requête /info, et à mesurer le temps nécessaire pour obtenir cette réponse.

Pour automatiser la recherche et compenser les variations du réseau, l’étape suivante consistait à écrire un script Python qui, pour un ensemble de valeurs :
Ouvre
nthreadsEnvoie une requête
checket mesure le temps nécessaire pour obtenir la réponse à une requête/infoparallèleFait la moyenne des temps et renvoie le résultat
J’ai utilisé n=5, ce qui s’est avéré suffisant. Il peut être nécessaire d’augmenter cette valeur selon la qualité de la connexion Internet et les caractéristiques temporelles de l’algorithme. Voici les résultats de la première exécution : notez la différence significative pour la valeur 4.
Comme la différence de temps n’apparaissait que lorsque 4 occupait la première position, l’étape suivante consistait à parcourir les autres paramètres. Le deuxième chiffre était 12.
Et ainsi de suite, jusqu’à trouver tous les bons paramètres : [4, 12, 77, 98, 35]
Flag capturé.
En résumé
Les attaques temporelles représentent une véritable menace et touchent aussi bien les petites que les grandes entreprises (comme Google). Même de faibles différences dans le temps d’exécution peuvent être détectées avec un échantillon relativement réduit (à l’échelle d’Internet), puis exploitées par les attaquants pour obtenir des informations et affiner leurs attaques par force brute. Dans Node.js en particulier, la boucle d’événements peut fournir un autre signal exploitable pour les attaques temporelles.
Voici quelques conseils pour protéger votre application contre ce type de faille :
Lors de la mise en œuvre des vérifications d’authentification, veillez à ce que le processus d’authentification dure toujours le même temps. Vous pouvez utiliser secure-compare ou d’autres packages pour effectuer des comparaisons à temps constant.
Vérifiez qu’ils ne présentent pas de vulnérabilités connues.
Testez la présence de vulnérabilités temporelles à l’aide d’outils.
Enfin, si vous souhaitez mettre vos compétences à l’épreuve et relever ces défis, essayez de participer l’année prochaine au Capture The Flag du CCC !
Lancez-vous dans les compétitions Capture The Flag
Apprenez à résoudre des défis de capture du drapeau en regardant à la demande notre atelier virtuel d’initiation.


