Allgemein Security

Der wfx-Secret-Stealer in CI: wie er funktioniert, wie du deine Repos prüfst und wie er beim nächsten Mal scheitert

12. Nov. 2026

Am 10. November 2026 begann jemand, mit Tausenden gestohlener GitHub-Zugangsdaten einen CI-Secret-Stealer in jedes Repository zu schleusen, auf das diese Accounts schreiben konnten. Aikido zählte innerhalb von rund 26 Stunden 4’837 kompromittierte Accounts und Probe-Pull-Requests in 61’764 Repositories. In mindestens 1’806 Repositories lief die Payload und flossen Secrets ab.

Der Angriff braucht keine Schwachstelle in GitHub. Er braucht ein Token, das pushen darf, und ein CI-System, das alles Gepushte mit den Secrets des Repositorys ausführt. Beides haben die meisten Organisationen, deshalb betrifft der Angriff auch jene, deren Repositories sauber geblieben sind.

Wir haben jedes Repository gescannt, auf das wir Zugriff haben, darunter das Projekt K8up. Dieser Beitrag erklärt, was der Angreifer tut, wie unser Scanner die Spuren findet und welche Einstellungen den Angriff gestoppt hätten.

So funktioniert der Angriff

1. Probe. Der Angreifer eröffnet einen Pull Request mit dem Titel docs: dependency audit notes aus einem Branch namens wfx-docs. Gelingt das, kann das gestohlene Token ins Repository schreiben. Die meisten dieser PRs waren bei Aikidos Veröffentlichung noch offen, die meisten Besitzer hatten also nichts bemerkt.

2. Payload. Danach folgt eine von drei Varianten:

  • Workflow-Injection: Ein Commit ci: refresh build cache fügt einem bestehenden Workflow rund 1’500 Zeilen hinzu. Der eingeschleuste Step übergibt ${{ toJSON(secrets) }} und ${{ toJSON(vars) }} an ein Skript, das sie per POST an http://194.150.166.244:8080/exfil schickt (oder an 18.143.168.96, gleicher Port und Pfad).
  • Install-Hook: Ein Commit chore: add ci helper fügt ein Skript .ci-xxxxxx.sh und einen postinstall-Eintrag in package.json hinzu. Jedes npm install führt es aus, in CI ebenso wie auf Entwickler-Laptops.
  • Wegwerf-Branch: Ein Commit chore: add dependency audit workflow legt .github/workflows/build-audit-xxxxxx.yml auf einem Branch an, der chore/ plus acht zufällige Zeichen heisst. Ein durch push ausgelöster Workflow läuft auf diesem Branch mit den Secrets des Repositorys, ohne main zu berühren.

3. Aufräumen. Spätestens nach einer Minute macht der Angreifer die Änderung rückgängig und löscht den Workflow-Run samt Logs. Das Repository sieht unberührt aus. Oft bleibt als einzige Spur eine Lücke in den Run-Nummern des Workflows.

4. Ausweitung und Persistenz. Der Stealer, der sich in Logs und Variablennamen selbst „wfx“ nennt, liest nicht nur Secrets. Er sammelt das GITHUB_TOKEN, die OIDC- und Actions-Runtime-Tokens, Credential-Dateien sowie Umgebung und Speicher der Runner-Prozesse. Das OIDC-Token tauscht er gegen Credentials für AWS, Google Cloud und Azure. Dann setzt er sich fest: Er erstellt langlebige AWS Access Keys, schreibt Role-Trust-Policies auf "Principal": {"AWS": "*"} um, legt in Azure Federated Identity Credentials namens wfx-ci-… an und setzt gefälschte success-Status unter den Namen der Required Checks des Repositorys.

Auch ein Repository ohne Cloud-Secrets kann also in einen Cloud-Account führen, sobald einer seiner Workflows über OIDC eine Rolle übernehmen kann.

Deine Repositories prüfen

Weil der Angreifer aufräumt, sagt der aktuelle Stand eines Repositorys wenig aus. Wir haben wfx-compromise-scan geschrieben, eine einzelne Python-Datei ohne Abhängigkeiten, die die Historie liest, die GitHub aufbewahrt:

  • Pull Requests seit dem 10. November, nach Titel und Head-Branch
  • Repository-Events, die das Anlegen, Löschen und Pushen von Branches festhalten, auch wenn der Branch nicht mehr existiert
  • Commit-Messages auf dem Default-Branch und auf jedem noch vorhandenen verdächtigen Branch
  • Workflow-Runs seit dem 10. November und Lücken in den Run-Nummern jedes Workflows, die gelöschte Runs zeigen
  • Artifacts und Actions-Caches namens wfx*
  • Dateinamen und Dateiinhalte: die Exfiltrations-IPs, die Variablen WF_CALLBACK und WF_CTOKEN, den SHA256 des Stealers, postinstall-Hooks, die .ci-*-Skripte aufrufen, und die Dateinamen der „GhostActions“-Welle vom Oktober, in deren Workflows die November-Payload in über 100 Repositories eingeschleust wurde
uv run wfx_scan.py                      # alle Repos, die dein Token sieht
uv run wfx_scan.py --org k8up-io --all  # eine Organisation, jedes Repo

Standardmässig scannt er nur die Repositories im Detail, in die seit dem 10. November gepusht wurde. Jeder Push, jedes Revert und jedes Löschen eines Branches aktualisiert diesen Zeitstempel, ein vom Angreifer berührtes Repository kann also nicht unter die Schwelle fallen. Eine GitHub-Suche nach dem Titel des Probe-PRs läuft zusätzlich über jeden Owner, den das Token sieht, ob im Detail gescannt oder nicht.

Unser Ergebnis: Die Suche umfasste 748 Repositories bei 9 Ownern, die 83 seit dem 10. November gepushten haben wir im Detail gescannt, und gefunden haben wir nichts. Bei K8up haben wir jedes Repository im Detail gescannt, ausser einem archivierten. Eines davon, ein privater Security-Advisory-Fork, lieferte auf jedem Actions-Endpunkt 404, seine Actions-Prüfungen führen wir deshalb als nicht verifiziert. Pull Requests, Events, Branches, Commits und Dateien sind sauber, ebenso alle anderen K8up-Repositories.

Der Scanner hat Grenzen, das README listet sie auf. Die Events-API liefert höchstens 300 Events der letzten 90 Tage, in einem aktiven Repository können die relevanten also fehlen. Auch Persistenz ausserhalb des Repositorys prüft er nicht: IAM Keys, Trust Policies und Federated Credentials prüfst du in den Cloud-Accounts, gefälschte Commit-Status in GitHub, indem du nach success-Status auf deinen Required Checks suchst, die deine CI nie gesetzt hat. Eine Prüfung, die nicht laufen konnte, meldet der Scanner als nicht verifiziert, nie als sauber.

Findet der Scanner etwas, betrachte jedes Secret, jede Variable und jede Cloud-Rolle, die die Workflows dieses Repositorys erreichen, als kompromittiert und rotiere alle.

Damit es nächstes Mal scheitert

Der Angriff verkettet mehrere Standardeinstellungen, und jedes einzelne gebrochene Glied hätte ihn gestoppt. Vom günstigsten an:

Secrets weg von Branches, die jeder anlegen kann

Die Wegwerf-Branch-Variante funktioniert, weil Repository-Secrets jedem Branch zur Verfügung stehen. Verschiebe wichtige Secrets in Environments mit Deployment-Branch-Regeln, die nur main (oder Release-Tags) zulassen, und verlange für Produktion Required Reviewers. Ein Workflow auf chore/a1b2c3d4 bekommt dann nichts.

OIDC-Trust an Branch oder Environment binden

Übernimmt ein Workflow über OIDC eine Cloud-Rolle, entscheidet die Trust Policy auf Cloud-Seite, wer hineinkommt. Prüfe den sub-Claim exakt, etwa repo:org/repo:environment:production oder repo:org/repo:ref:refs/heads/main, nie repo:org/repo:*. Mit einer Wildcard kann jeder Branch die Rolle übernehmen, auch der des Angreifers.

GITHUB_TOKEN standardmässig nur lesend

Stelle die Default-Workflow-Permissions der Organisation auf nur lesend und vergib contents: write, packages: write oder id-token: write pro Job, wo es nötig ist. Das begrenzt, was das Token eines legitimen Workflows kann, ist aber eine Voreinstellung und keine Obergrenze: Wer einen Workflow ändern kann, ändert auch seinen permissions:-Block, und das gestohlene Benutzer-Token bleibt davon unberührt. Wirksam wird es zusammen mit den Einschränkungen für Workflow-Änderungen im nächsten Abschnitt.

Einschränken, wer Branches anlegt und Workflows ändert

Rulesets können das Anlegen von Branches auf benannte Muster beschränken. Eine Regel, die direkte Pushes auf main blockiert, stoppt die Workflow-Injection. In privaten und internen Repositories kann ein Push-Ruleset jeden Push blockieren, der .github/workflows/** ändert. Das trifft auch legitime Workflow-Änderungen, gib also einem kleinen Team Bypass-Rechte und lass Workflow-Änderungen über dieses Team laufen. Lege CODEOWNERS für .github/ an und aktiviere im Branch-Ruleset „Require review from Code Owners“: CODEOWNERS allein fordert das Review nur an, verlangt es aber nicht.

Install-Skripte abschalten

npm config set ignore-scripts true auf Entwicklerrechnern und in CI stoppt die postinstall-Variante. Ab pnpm 10 sind Install-Skripte von Abhängigkeiten standardmässig blockiert, nicht aber die Skripte in der package.json deines eigenen Projekts, und genau dort setzt diese Variante ihren Hook. Setze ignore-scripts=true deshalb auch für pnpm, und gib die wenigen Abhängigkeiten, die einen Build-Schritt brauchen, explizit frei.

Den Wert eines gestohlenen Tokens verkürzen

Die Accounts wurden nicht einzeln gephisht. Ihre Tokens sind irgendwo abgeflossen, und Aikido fand Überschneidungen mit der GhostActions-Welle vom Oktober, deren Stealer auf Entwicklerrechnern ~/.git-credentials, SSH-Keys und CLI-Credentials mitnahm. Nutze Fine-grained Tokens, beschränkt auf die nötigen Repositories und mit Ablaufdatum. Erzwinge in der Organisation SSO und 2FA mit Hardware-Keys, und prüfe, wer noch Schreibrechte hat. Jeder vergessene Collaborator ist ein weiteres Token, das pushen kann.

Beim Aufräumen alarmieren, ausserhalb des Repositorys

Einen Workflow-Run zu löschen ist im normalen Betrieb selten und für diesen Angriff zentral. Streame das Audit-Log der Organisation in ein System unter deiner Kontrolle und alarmiere bei workflows.delete_workflow_run, bei neuen Deploy Keys und bei neuen Bypasses der Branch Protection. Dieser Alarm meldet eine Tatsache („ein Run wurde gelöscht“), braucht also kein Tuning, und er feuert ausserhalb des Repositorys, das der Angreifer kontrolliert. Audit-Log-Streaming wird auf einem Enterprise-Account von GitHub Enterprise Cloud eingerichtet; ohne einen solchen bleibt eine regelmässige Prüfung auf Lücken in den Run-Nummern (der run-gap-Check des Scanners) als Teillösung. Sie sieht einen gelöschten Run erst, wenn ein späterer Run desselben Workflows erhalten bleibt, und einen Workflow, dessen Runs alle gelöscht wurden, sieht sie gar nicht.

Einschränken, wohin Runner Daten senden

Der Stealer exfiltriert per unverschlüsseltem HTTP an eine IP-Adresse. Eine Egress-Allowlist auf Self-hosted Runnern oder ein Egress-Filter-Step auf Hosted Runnern macht aus einem erfolgreichen Run einen gescheiterten.

Den Scan wiederholen

Ein sauberes Ergebnis heute sagt nichts über die nächste Welle. Der Scanner endet bei einem HIGH-Fund mit einem Exit-Code ungleich null und kann deshalb zeitgesteuert aus einem Repository laufen, das ausser einem Read-only-Token keine Secrets enthält. WARN-Funde wie Lücken in den Run-Nummern und Prüfungen, die nicht laufen konnten, enden trotzdem mit null. Lies deshalb neben dem Exit-Code auch den Bericht.

Zusammenfassung

Die wfx-Kampagne brauchte ein gestohlenes Token, Secrets für alle Branches und die Freiheit, ihre eigenen Spuren zu löschen. Environment-gebundene Secrets und exakte OIDC-Trust-Bedingungen nehmen ihr die Beute. Nur lesende Default-Tokens und Rulesets nehmen ihr den grössten Teil der Reichweite. Ein Audit-Log-Alarm bei gelöschten Workflow-Runs nimmt ihr das Versteck. Nichts davon braucht neue Werkzeuge.

Aarno Aukia

Aarno ist Mitgründer der VSHN AG und als CTO für die technische Begeisterung zuständig.

Kontaktiere uns

Unser Expertenteam steht für dich bereit. Im Notfall auch 24/7.

Kontakt