Retours sur Shai Hulud 2.0

Retours sur Shai Hulud 2.0
Image générée par IA

Après une première vague mi-septembre qui avait fait un grand echo dans l'écosystème Javascript en compromettant des paquets largement utilisés comme ngx-bootstrap, Shai Hulud (une référence au ver de Dune) revient dans une version 2.0, fonctionnant plus ou moins sur le même principe.

Comment ça a commencé ?

Les attaquants ont utilisé une technique exploitant une "faille" de Github Actions : le trigger pull_request_target. Si je simplifie, ils ont pu :

  • ajouter du code à exécuter derrière ce trigger, dont le but est de récupérer tout type de secrets qui seraient présents dans le contexte d'exécution de la pipeline
  • soumettre une PR, et donc faire tourner une pipeline avec le code modifié
  • récupérer des tokens npm légitimes de l'organisation ciblée
  • publier grâce à ces tokens volés une nouvelle version modifiée du paquet npm qui contient le payload du ver Shai Hulud.

Les premières victimes de cette attaque, aka patient zéro, comme PostHog, AsyncAPI et Postman, ont tous subit les conséquences de cette technique. Deux points communs entre ces acteurs : du code open source largement utilisé et la présence du trigger Github Actions permettant un usage de la CI... abusif.

Qu'est-ce qu'on risque ?

Le but du ver est de voler divers types de secrets (majoritairement Github / npm dans le but de continuer à propager l'attaque, et AWS, GCP, Azure pour… on ne sait pas encore 🧐) en les rendant publics sur Github. Bref, personne n'a trop envie de divulguer ces informations...

Et pourtant, étant donné que nos patients zéro sont des paquets populaires, beaucoup, beaucoup, beaucoup de monde s'est retrouvé "infecté", et par extension beaucoup d'autres paquets npm se retrouvent compromis. Quelques chiffres pour vous donner un ordre d'idée :

  • plus de 25 000 dépôts github publics au pic de l'attaque, contenant chacun plusieurs secrets
  • environs 800 paquets compromis !

Qui plus est, le ver est destructif. Je m'explique : dans le cas où des secrets Github et / ou npm auraient été volés avec succès, leur validité est testée régulièrement en tâche de fond. Si l'accès est perdu (rotation du secret par exemple), le ver tente de supprimer toutes les données de l'utilisateur (un bon rm -rf $HOME en clair). Une conséquence pas très réjouissante... et qui lève tout doute si jamais il y en avait un sur l'aspect malveillant de l'attaque.

Pourquoi donc encore l'écosystème Javascript / npm ?

Décidément pas épargné, c'est effectivement à nouveau npm qui a été ciblé et par extension l'écosystème Javascript.

Plusieurs raisons à tout ça :

  • premièrement, Javascript est omniprésent ; côté serveur, côté client, il y a de forte chance pour qu'un projet utilise cette technologie (il suffit de regarder la quantité stratosphérique de projets JS / TS sur Github).
  • deuxièmement, l'écosystème Javascript dans son ensemble est extrêmement interdépendant (pleins de toutes petites librairies qui sont à la base de toutes les autres). Il suffit d'ajouter une librairie sur son projet pour récupérer en un instant des dizaines voir des centaines d'autres paquets dont la librairie dépend transitivement.
L'installation d'un paquet npm introduit en moyenne une confiance implicite envers 79 paquets tiers et 39 mainteneurs, créant ainsi une surface d'attaque étonnamment vaste. (https://www.endorlabs.com/learn/how-to-defend-against-npm-software-supply-chain-attacks)

On a donc une surface d'attaque très large et propice à ce type d'attaque.

Et donc, comment vérifier que l'on n'est pas touché ?

Le premier réflexe est de vérifier si l'on possède une version compromise d'un paquet npm touché par l'attaque (donc un de ceux victimes d'un usage abusif de leur CI via Github Actions ou un paquet compromis par propagation). Le ver laisse des traces, l'objectif va donc être d'en repérer au moins une. Pour cela, rien de plus simple que d'exécuter cette commande, qui va scanner votre dossier home à la recherche des fichiers trahissant le ver :

find ~ -type f \( -name 'setup_bun.js' -o -name 'bun_environment.js' -o -name 'cloud.json' -o -name 'truffleSecrets.json' -o -name 'actionsSecret.json' \) 2>/dev/null

Si la commande retourne des résultats, c'est un signe probable de compromission (sauf si vous êtes habitués à utiliser bun et trufflehog). Dans tous les cas, il vaut mieux :

  • supprimer ces fichiers
  • clear le dossier node_modules pour réinstaller proprement les dépendances à partir d'un lockfile sain

Évidemment, ça signifie aussi qu'il faut vérifier votre compte Github à la recherche de dépôt d'exfiltration, et d'entreprendre entre autre une rotation de tous les secrets qui auraient potentiellement été divulgués. Mais ça, je vous laisse lire d'autres articles comme celui-ci qui en parlent beaucoup mieux.

Après ça, qu'est-ce que l'on fait ?

Il va falloir se faire à l'idée qu'il faut se préparer à la multiplication de ce type d'attaque. En tant que dev, on peut facilement se prémunir de ce type d'attaque. Je vous ai fait un petit listing ci-dessous.

  1. Eviter l'exécution des scripts pre-install / post-install

C'est le plus simple à faire, et ça empêche tout bonnement l'exécution du payload du ver lorsque vous faites un npm install. Inconvénient : il est assez probable que des paquets existants s'appuient sur ces scripts pour exécuter une installation légitime. Mais pas de panique : il suffit d'exécuter les commandes de pre-install et post-install à la main (un peu de setup à documenter dans son README 😄).

À noter : certains gestionnaires de paquets comme pnpm ont déjà fait le choix de désactiver ces scripts par défaut.

# Npm config 
npm config set ignore-scripts true --global

# Yarn v2+ config
yarn config set enableScripts false --home

# Yarn v1 config
yarn config set ignore-scripts true
  1. Mettre un "cooldown" sur le téléchargement de nouvelles dépendances

Par défaut, à chaque npm install (vous installez le projet pour la 1ʳᵉ fois ou vous ajoutez un nouveau paquet), npm va résoudre l'arbre complet de vos dépendances, en satisfaisant les contraintes définies grâce au ^ ou ~ dans votre package.jsonet transitivement dans chaque package.json des dépendances de votre projet. Cela veut simplement dire qu'une version ^2.1.0 peut être résolue à 2.1.1 ou même 2.123.0. En va de même pour une version ~2.1.0 qui sera limitée à toutes les versions 2.1.x (par exemple la 2.1.1).

En clair, une dépendance compromise par Shai Hulud peut rapidement être récupérée, car présente dans l'immense graphe de vos dépendances. Les attaquants ont pris soin de déployer la version compromise du paquet via un incrément du numéro de patch (donc 2.1.0 est une version saine et 2.1.1 est une version compromise), facilitant leur diffusion.

Un bon moyen de se prémunir de ce problème est donc d'attendre une durée suffisamment grande avant d'installer une nouvelle version d'un paquet, ce qui permettrait à priori de détecter (via la communauté et les acteurs de la cybersécurité qui ont toujours un œil sur ce type d'attaque) les paquets illégitimes.

Ci-dessous un exemple de configuration imposant un délai d'une semaine avant qu'un paquet soit considéré comme installable.

À noter : la version legacy de Yarn ne supporte pas cette fonctionnalité, et que npm n'a pas encore implémenté un mécanisme similaire (l'exemple se base sur une option de la CLI prévue pour un autre besoin ; on obtient le même résultat, mais c'est moins pratique à utiliser).

# npm (MacOS)
npm install --before=$(date -v-7d +%Y-%m-%d)

# npm (Linux)
npm install --before=$(date --date="7 days ago" +%Y-%m-%d)

# .yarnrc.yml
npmMinimalAgeGate: "7d"

# pnpm-workspace.yaml
minimumReleaseAge: 10080 
  1. Eviter la résolution du graphe de dépendances quand ce n'est pas nécessaire

Toutes les versions de nos dépendances qui ont été résolues lors d'un npm install sont stockées dans le package-lock.json (même fonctionnement pour les autres packages manager, même si ce n'est pas le même nom de fichier). Si à un instant T toutes nos dépendances sont saines, à un instant T+1, si l'on reproduit cette même configuration de dépendances, elles le seront toujours, même si Shai Hulud se propage à l'extérieur. Évidemment, toute nouvelle résolution de dépendance (coucou le npm install) met fin à ce postulat ; mais on a dans ce cas les deux précédentes configurations qui sont censées nous protéger.

Je vous conseille donc d'utiliser npm ci en lieu et place de npm install pour tout ce qui concerne la "récupération et configuration à l'identique des dépendances du projet" (soit lorsqu'on récupère un projet existant, soit dans notre environnement de CI). Ainsi, on ne considère plus npm install comme la commande par défaut mais seulement comme une exception pour ajouter une nouvelle dépendance.

Vous pouvez d'ores et déjà aller vérifier :

  • vos README et l'explication présente pour l'installation du projet (install -> ci)
  • vos templates de CI, qui devraient au mieux utiliser le cache d'installation et au pire réaliser un npm ci

Pour yarn et pnpm, l'option --frozen-lockfile assure de réutiliser la résolution de dépendances persistée dans le lockfile.

Pour aller plus loin

Si vous avez tout suivi, c'est à peu près tout ce qu'on peut faire côté dev. D'ailleurs, avec ces recommandations, on n'empêche pas du tout les attaques supply chain comme Shai Hulud de se produire. On évite simplement d'être touché et on limite donc aussi un peu sa propagation.

L'idéal serait de pouvoir empêcher tout bonnement l'attaque de se produire. Je vous laisse sur quelques pistes de réflexion pour regarder un peu plus loin :

  • ne pas utiliser les dépôts publics de npm, mais plutôt utiliser un proxy de dépendances avec un mécanisme d'analyse en continu (Jfrog Xray, Sonatype Nexus Firewall, Snyk Cloud Gateway...). Ça a un certain coût de mise en place, mais c'est idéal contre ce type d'attaque.
  • prouver la légitimité des nouvelles versions des paquets en n'autorisant que des auteurs connus à l'avance de les publier (https://docs.npmjs.com/trusted-publishers).

L'actualité ne nous épargnant pas, n'oubliez pas de mettre à jour vos versions de React Server (surtout si vous utilisez un framework SSR), pour éviter que la CVE React2Shell ne se transforme en attaque avérée sur vos projets !

Je vous dis à très vite 🚀