Kylian Nézan
06/04/26 · 6 min

GitHub Actions : du tag hijacking à l'impostor commit

J'ai rejoué de bout en bout une attaque supply chain sur un pipeline GitHub Actions. Tag mutable, SHA pinning, impostor commit : ce qui protège vraiment, et ce qui ne suffit pas.

En mars 2025, l'action tj-actions/changed-files a été compromise. Un attaquant a déplacé tous les tags, de v1 à v45.0.7, vers un commit malveillant qui recopiait les secrets du runner directement dans les logs publics du pipeline. Plus de 23 000 dépôts utilisaient cette action (CVE-2025-30066). Le détail qui m'a marqué : aucune des victimes n'avait modifié une seule ligne de son workflow. Le code substitué est arrivé tout seul, parce qu'un tag Git n'est rien d'autre qu'un post-it qu'on peut recoller ailleurs.

Je voulais comprendre cette famille d'attaques de l'intérieur, pas en lisant un post-mortem. Alors je l'ai reconstruite sur mes propres dépôts, du commit légitime jusqu'à l'exfiltration des secrets. Voici ce que ça donne, étape par étape, et pourquoi la parade évidente (le SHA pinning) ne suffit pas à fermer la porte.

Le décor tient en deux dépôts : Kylian14/demo-actions, l'action réutilisable, et Kylian14/demo-app, le projet qui la consomme dans sa CI.

La chaîne de confiance que personne ne regarde

Un workflow GitHub appelle une action tierce avec une ligne :

- uses: Kylian14/demo-actions@v1

@v1, c'est une référence. Par défaut, presque tout le monde épingle sur un tag de version comme celui-là, pour la lisibilité et la stabilité. Le problème est que ce tag est mutable. Côté action, le propriétaire (ou quiconque a poussé un token volé) peut faire pointer v1 vers le commit de son choix. Côté projet qui consomme, rien ne bouge, rien ne prévient. Le tag est résolu au moment où le job tourne, et il est résolu vers ce qu'il pointe à cet instant.

Autrement dit : vous exécutez du code tiers, avec accès à vos secrets CI/CD, sur la base d'une promesse qui peut changer pendant votre sommeil.

Attaque 1 : le tag hijacking

Au départ, tout est normal. Le tag v1 pointe sur le commit dae8c12, qui contient le vrai code de l'action. Le workflow de la victime tourne, et le job affiche fièrement son code légitime.

Log CI : code légitime exécuté via le tag @v1
Log CI : code légitime exécuté via le tag @v1

Puis l'attaquant déplace le tag. Deux commandes suffisent :

git tag -f v1 03d2b04      # 03d2b04 = commit malveillant
git push --force origin v1

Le commit 03d2b04 est un fils du commit légitime, avec une charge utile en plus. Le tag v1 ne pointe plus sur dae8c12 mais sur lui. Le workflow de la victime, lui, est strictement identique : toujours uses: Kylian14/demo-actions@v1.

Le tag v1 est déplacé du commit légitime vers le commit malveillant. Le workflow de la victime ne change pas.
Le tag v1 est déplacé du commit légitime vers le commit malveillant. Le workflow de la victime ne change pas.

Au run suivant, le même @v1 exécute le code de l'attaquant. Pas de PR, pas de notification, pas de diff à reviewer côté victime. Les secrets partent dans les logs.

Log CI : code malveillant exécuté via le même tag @v1
Log CI : code malveillant exécuté via le même tag @v1

Le job affiche GITHUB_TOKEN, RUNNER_OS, GITHUB_REPOSITORY et GITHUB_ACTOR. C'est exactement ce qui est arrivé aux 23 000 dépôts de tj-actions. Le moment où mon propre pipeline a recraché mes variables sans que je touche à quoi que ce soit, ça refroidit.

La parade : épingler sur un SHA

La recommandation officielle de GitHub, reprise par Dependabot, StepSecurity et zizmor, est d'épingler sur un SHA de commit complet plutôt que sur un tag :

# Avant : référence mutable
- uses: Kylian14/demo-actions@v1

# Après : référence immuable
- uses: Kylian14/demo-actions@dae8c1244426330fedc8b2990e3b2acbac1980ba
Un tag est un nom déplaçable. Un SHA est l'empreinte du contenu du commit.
Un tag est un nom déplaçable. Un SHA est l'empreinte du contenu du commit.

Un SHA de commit est un hash du contenu. On ne peut pas le déplacer, on ne peut pas le réécrire sans changer le SHA lui-même. Si l'attaquant force v1 vers son commit, mon workflow épinglé sur dae8c12... continue d'exécuter le bon code, parce qu'il ne demande plus jamais "donne-moi v1", il demande "donne-moi ce commit précis".

Log CI : code légitime exécuté via le SHA complet
Log CI : code légitime exécuté via le SHA complet

Ça ferme le tag hijacking. Net. Mais ça déplace le problème au lieu de le supprimer, et c'est là que beaucoup d'équipes s'arrêtent trop tôt.

Attaque 2 : l'impostor commit

Le SHA garantit que vous exécutez toujours le même commit. Il ne garantit pas que ce commit est sain. Si vous épinglez sur un SHA malveillant, le SHA fait son travail à la perfection : il vous sert le code malveillant, exactement le même, à chaque run.

Reste à faire entrer ce SHA dans le workflow de la victime. C'est là que l'attaque redevient humaine.

Schéma de l'impostor commit : la PR déguise le changement de SHA en bump de version. Le SHA est valide, le commit n'a jamais été ouvert.
Schéma de l'impostor commit : la PR déguise le changement de SHA en bump de version. Le SHA est valide, le commit n'a jamais été ouvert.

Le schéma : l'attaquant part du code légitime (par exemple via un fork), ajoute un commit enfant dab001a qui ressemble à une mise à jour anodine mais embarque la charge utile, puis il ouvre une pull request sur le projet victime. Pas une PR louche. Une PR qui a l'air d'une corvée de maintenance.

PR : un changement de SHA déguisé en bump de version v1.01
PR : un changement de SHA déguisé en bump de version v1.01

Regardez le titre : fix(ci): update checkout pinning. Le genre de PR qu'on approuve en diagonale un vendredi soir. Le diff ne change qu'une ligne :

- uses: Kylian14/demo-actions@dae8c1244426330fedc8b2990e3b2acbac1980ba # v1
+ uses: Kylian14/demo-actions@dab001addf2662895f42e5e6aea4b7d879c4d43a # v1.01

Le détail vicieux est dans le commentaire. # v1.01 laisse croire à un simple bump de version. Sauf que ce commentaire est du texte libre : il ne prouve rien. Le SHA dab001a... ne correspond à aucun tag v1.01 officiel, il pointe sur le commit malveillant. Un reviewer qui fait confiance au SHA parce qu'il est "fixe et précis", sans aller lire le contenu du commit cible, approuve l'exfiltration.

Log CI : code malveillant exécuté via un SHA fixe mais malveillant
Log CI : code malveillant exécuté via un SHA fixe mais malveillant

Même résultat que pour le tag hijacking : GITHUB_TOKEN, RUNNER_OS, GITHUB_REPOSITORY (Kylian14/demo-app) et GITHUB_ACTOR dans les logs. Sauf que cette fois, le workflow est épinglé sur un SHA, en suivant la bonne pratique à la lettre. C'est ça, l'impostor commit : un SHA parfaitement valide qui pointe vers du code que personne n'a lu.

Ce qui protège, et ce qui ne suffit pas

Défense en profondeur : chaque couche et ce qu'elle protège vraiment
Défense en profondeur : chaque couche et ce qu'elle protège vraiment

Le SHA pinning n'est pas inutile, il est partiel. Il neutralise le tag hijacking, et c'est déjà énorme vu la fréquence de cette attaque. Mais il ne dispense pas de vérifier ce que contient le commit. Mes conclusions après avoir tout cassé moi-même :

  • Auditer le contenu, pas seulement la référence. À chaque mise à jour de SHA, lisez le diff du commit cible, pas juste la ligne qui change dans votre workflow. Méfiez-vous des scripts chargés dynamiquement à l'exécution. Des outils comme zizmor et StepSecurity Harden-Runner automatisent une partie de cette vérification.
  • Protéger .github/workflows/ avec CODEOWNERS. Un changement de SHA est un changement de code exécuté avec vos secrets. Il mérite une review obligatoire par une personne qui connaît le dossier, plus des branch protection rules sur main.
  • Forker et auto-héberger les actions critiques. Pour les dépendances sensibles, le plus solide reste de forker l'action dans votre organisation et de pointer sur votre fork contrôlé. Plus de dépendance externe que vous ne maîtrisez pas.

Aucune de ces mesures n'est magique prise seule. Ensemble, elles transforment une attaque silencieuse en une attaque qui doit franchir plusieurs portes verrouillées.

Du tag hijacking à la défense en profondeur
Du tag hijacking à la défense en profondeur

Ce que je retiens

La supply chain CI, c'est de la confiance transitive : vous faites confiance à une action, qui fait confiance à ses dépendances, qui font confiance à leurs mainteneurs. Le tag hijacking exploite le maillon mutable. Le SHA pinning solidifie ce maillon, mais l'impostor commit montre qu'on peut toujours vous faire pointer, de votre plein gré, vers du code piégé.

La vraie défense n'est pas une ligne de YAML. C'est une habitude : traiter chaque mise à jour de dépendance CI comme du code à reviewer, parce que c'en est. Reproduire l'attaque moi-même m'a appris ça mieux que n'importe quel article. Y compris celui-ci.


Références