The wfx CI secret stealer: how it works, how to check your repos, and how to make it fail next time

On November 10, 2026, someone started using thousands of stolen GitHub credentials to plant a CI secret stealer in every repository those accounts could write to. Aikido counted 4,837 compromised accounts and probe pull requests in 61,764 repositories within about 26 hours. The payload ran, and secrets were exfiltrated, in at least 1,806 repositories.
The attack needs no vulnerability in GitHub. It needs a token that is allowed to push, and a CI system that runs whatever is pushed with the secrets the repository holds. Most organizations have both, which is why the attack matters even to those whose repositories came out clean.
We scanned every repository we have access to, including the K8up project. This post explains what the attacker does, how the scanner we wrote finds the traces, and which settings would have stopped the attack.
How the attack works
1. Probe. The attacker opens a pull request titled docs: dependency audit notes from a branch named wfx-docs. If that succeeds, the stolen token can write to the repository. Most of these PRs were still open when Aikido published, so most owners had not noticed.
2. Payload. One of three variants follows:
- Workflow injection: a commit
ci: refresh build cacheadds about 1,500 lines to an existing workflow. The injected step hands${{ toJSON(secrets) }}and${{ toJSON(vars) }}to a script that POSTs them tohttp://194.150.166.244:8080/exfil(or to18.143.168.96, same port and path). - Install hook: a commit
chore: add ci helperadds a.ci-xxxxxx.shscript and apostinstallentry inpackage.json. Anynpm installruns it, in CI and on developer laptops. - Throwaway branch: a commit
chore: add dependency audit workflowadds.github/workflows/build-audit-xxxxxx.ymlon a branch namedchore/plus eight random characters. A workflow triggered bypushruns on that branch with the repository’s secrets, without touchingmain.
3. Cleanup. Within at most a minute the attacker reverts the change and deletes the workflow run and its logs. The repository looks untouched. Often the only remaining trace is a gap in the workflow’s run numbers.
4. Escalation and persistence. The stealer, which calls itself “wfx” in its logs and variable names, does more than read secrets. It collects the GITHUB_TOKEN, the OIDC and Actions runtime tokens, credential files, and the environment and memory of the runner processes. It exchanges the OIDC token for AWS, Google Cloud and Azure credentials. Then it entrenches itself: it creates long-lived AWS access keys, rewrites role trust policies to "Principal": {"AWS": "*"}, adds Azure federated identity credentials named wfx-ci-…, and posts forged success statuses under the names of the repository’s required checks.
A repository with no cloud secrets in it can therefore still lead into a cloud account, if any of its workflows can assume a role through OIDC.
Checking your repositories
Because the attacker cleans up, the current state of a repository proves little. We wrote wfx-compromise-scan, a single Python file with no dependencies, which reads the history GitHub keeps:
- pull requests created since November 10, by title and head branch
- repository events, which keep branch creations, deletions and pushes after the branch is gone
- commit messages on the default branch and on any suspicious branch still present
- workflow runs since November 10, and gaps in each workflow’s run numbers, which show runs that someone deleted
- artifacts and Actions caches named
wfx* - file names and file contents: the exfiltration IPs, the
WF_CALLBACKandWF_CTOKENvariables, the stealer’s SHA256,postinstallhooks calling.ci-*scripts, and the file names of the October “GhostActions” wave, whose workflows the November payload was injected into in more than 100 repositories
uv run wfx_scan.py # all repos your token can see uv run wfx_scan.py --org k8up-io --all # one organization, every repo
By default it deep scans only the repositories pushed to since November 10. Every push, revert and branch deletion updates that timestamp, so a repository the attacker touched cannot fall below the cutoff. A GitHub search for the probe PR title runs across every owner the token can see, deep scanned or not.
Our result: the search covered 748 repositories across 9 owners, the 83 pushed to since November 10 were deep scanned, and nothing was found. For K8up we deep scanned every repository except one archived one. One of them, a private security advisory fork, returned 404 for every Actions endpoint, so we list its Actions checks as unverified. Its pull requests, events, branches, commits and files are clean, and so is every other K8up repository.
The scanner has limits, and the README lists them. The events API keeps at most 300 events from the last 90 days, so a busy repository can lose the relevant ones. Persistence outside the repository is not checked either: IAM keys, trust policies and federated credentials have to be checked in the cloud accounts, and forged commit statuses in GitHub, by looking for success statuses on your required checks that your CI never posted. A check that could not run is reported as unverified, never as clean.
If the scanner does find something, assume every secret, variable and cloud role reachable from that repository’s workflows is compromised, and rotate all of them.
Making it fail next time
The attack chains several defaults, and breaking any single link would have stopped it. Starting with the cheapest:
Keep secrets away from branches anyone can create
The throwaway-branch variant works because repository secrets are available to every branch. Move secrets that matter into environments with deployment branch rules that allow only main (or release tags), and add required reviewers for production. A workflow on chore/a1b2c3d4 then gets nothing.
Pin OIDC trust to a branch or environment
If a workflow assumes a cloud role through OIDC, the cloud-side trust policy decides who gets in. Match the sub claim exactly, for example repo:org/repo:environment:production or repo:org/repo:ref:refs/heads/main, never repo:org/repo:*. A wildcard lets any branch, including the attacker’s, assume the role.
Make the GITHUB_TOKEN read-only by default
Set the organization’s default workflow permissions to read-only and grant contents: write, packages: write or id-token: write per job where it is needed. This limits what a legitimate workflow’s token can do, but it is a default, not a cap: an attacker who can edit a workflow can also edit its permissions: block, and the stolen user token is unaffected. It pays off together with the restrictions on workflow changes below.
Restrict who can create branches and change workflows
Rulesets can restrict branch creation to named patterns. A rule that blocks direct pushes to main stops the workflow-injection variant. On private and internal repositories, a push ruleset can block any push that changes .github/workflows/**. It blocks legitimate workflow changes too, so give a small team bypass permission and route workflow changes through them. Add CODEOWNERS for .github/ and turn on “Require review from Code Owners” in the branch ruleset: CODEOWNERS alone only requests the review, it does not require it.
Disable install scripts
npm config set ignore-scripts true on developer machines and in CI stops the postinstall variant. pnpm 10 and later block install scripts of dependencies by default, but not the scripts in your own project’s package.json, which is where this variant puts its hook. Set ignore-scripts=true for pnpm as well, and allow the few dependencies that need a build step explicitly.
Shorten what a stolen token is worth
The accounts were not phished one at a time. Their tokens leaked from somewhere, and Aikido found that the accounts overlap with the October GhostActions wave, whose developer-machine stealer took ~/.git-credentials, SSH keys and CLI credentials. Prefer fine-grained tokens scoped to the repositories they need, with an expiry. Enforce SSO and hardware-key 2FA in the organization, and review who still has write access. Every stale collaborator is one more token that can push.
Alert on the cleanup, outside the repository
Deleting a workflow run is rare in normal work and central to this attack. Stream the organization audit log to a system you control and alert on workflows.delete_workflow_run, on new deploy keys, and on new branch-protection bypasses. That alert states a fact (“a run was deleted”), so it needs no tuning, and it fires from outside the repository the attacker controls. Audit log streaming is configured on a GitHub Enterprise Cloud enterprise account; without one, a scheduled check for gaps in run numbers (the scanner’s run-gap check) is a partial fallback. It sees a deleted run only once a later run of the same workflow survives, and it cannot see a workflow whose runs were all deleted.
Limit where runners can send data
The stealer exfiltrates over plain HTTP to an IP address. An egress allowlist on self-hosted runners, or an egress-filtering step on hosted runners, turns a successful run into a failed one.
Run the scan again
A clean result today says nothing about the next wave. The scanner exits non-zero on a HIGH finding, so it can run on a schedule from a repository that holds no secrets beyond a read-only token. WARN findings such as run-number gaps, and checks that could not run, still exit zero, so read the report as well as the exit code.
Summary
The wfx campaign needed a stolen token, branch-wide secrets and the freedom to delete its own evidence. Environment-scoped secrets and exact OIDC trust conditions take away the payoff. Read-only default tokens and rulesets take away most of the reach. An audit-log alert on deleted workflow runs takes away the hiding. None of these needs new tooling.