wfx: Hijacked GitHub Accounts Are Pushing a CI Secret Stealer to Tens of Thousands of Repos
Stolen GitHub tokens are pushing a CI secret stealer into thousands of repos. How to check yours, what to rotate, and what rotation misses.

- wfx is a CI secret stealer pushed into GitHub repositories since 10 November 2026 using stolen GitHub credentials. It dumps every secret a workflow can see, then reverts its commit and deletes the run.
- On 12 November at 09:17 UTC, GitHub search showed 64,865 pull requests titled "docs: dependency audit notes", 58,543 of them (90%) still open. At least 1,806 repos have confirmed credential theft.
- A missing workflow run number right after a suspicious commit is often the only trace, because the attacker deletes the run and its logs within about a minute.
- Rotating secrets is not enough. The payload can leave an AWS trust policy for any account, new access keys and an Azure federated credential named wfx-ci that survive rotation.
- Newer copies send to Telegram instead of the three collection IPs, so an IP blocklist alone will miss them.
Open the Actions tab of a repo you own and look at the run numbers. If workflow run #1963 is followed by #1965, someone deleted #1964. In this campaign, that gap is often the only trace left.
wfx is a CI secret stealer that attackers have been pushing into GitHub repositories since 10 November 2026, using stolen GitHub credentials. It is a shell script of about 1,500 lines that runs inside GitHub Actions, reads every secret the job can see, tries to turn the job's identity into cloud access, and posts the haul to a server. Then it reverts its own commit and deletes the run. The whole thing can take about a minute.
If an account you control opened a pull request titled "docs: dependency audit notes", treat that account as compromised, even if no payload ran. If a payload did run, rotating secrets is not enough. The attacker may have left an AWS trust policy, an access key or an Azure federated credential behind.
How big is it, and why do the numbers disagree?
Every figure published so far is a lower bound, and they were taken at different times with different methods. That is why you will see 8,000, 61,000 and 1,800 attached to the same campaign. They count different things.
| What was counted | Figure | How it was counted |
|---|---|---|
| Hijacked accounts that opened probe PRs | 4,837 | Earliest full tally |
| Repositories probed | 61,764 | Earliest full tally |
| Pull requests titled "docs: dependency audit notes" | 64,865 (58,543 still open, 90%) | Our own GitHub search, 09:17 UTC, 12 Nov |
| Repositories where credentials were confirmed taken | At least 1,806 | Confirmed lower bound |
| Public repos with a payload commit on the default branch | 8,093 | GitHub search, 04:36 UTC, 12 Nov. Public default branches only |
| Commits titled "ci: refresh build cache" | About 19,900 | Our own GitHub search, 09:17 UTC, 12 Nov |
| Commits titled "chore: add ci helper" | About 2,600 | Our own GitHub search, 09:17 UTC, 12 Nov |
| Probe PRs into repos the hijacked account does not own | 10,431 | Reached through collaborator or org access |
| Hijacked accounts that publish packages | At least 50 | npm, Go, Packagist, PyPI and crates |
Here is how to read that table. A probe is not a payload. The attacker opens a harmless-looking pull request first to check that a stolen token can write, and only pushes the stealer where the token's permissions and the repo's setup let it run. So 64,865 probes does not mean 64,865 breaches. It means 64,865 places where an attacker had a working login.
Search counts also undercount. GitHub search covers public repositories and default branches, so private repos, other branches and repos that only got the probe are invisible to it. The commit counts we pulled on the morning of 12 November were slightly lower than figures taken earlier the same day, which tells you how soft those numbers are.
What happens inside a repository
- Probe. A pull request titled "docs: dependency audit notes" is opened from a branch called wfx-docs, adding a file named docs/wfx-audit.md. It exists to confirm the token can write.
- Payload. The stealer is pushed in one of four forms (table below). The commit message looks like routine housekeeping.
- Run. The workflow executes on a GitHub runner, or the npm hook executes on whatever machine runs npm install.
- Cleanup. Within about a minute the attacker pushes a revert commit and deletes the workflow run and its logs. The default branch looks normal afterwards.
| Variant | Commit message | Public repos (default branch, 04:36 UTC 12 Nov) | Where it runs |
|---|---|---|---|
| Steps added to an existing workflow | ci: refresh build cache | About 7,300 | GitHub runner |
| Hidden .ci-xxxxxx.sh plus an npm hook in package.json | chore: add ci helper | 771 | Runner and developer machines |
| New workflow on a throwaway branch | chore: add dependency audit workflow | 16 (new workflow in an empty repo) | GitHub runner |
| Appended to a shell script CI already runs, such as update.sh | Varies | Not counted | GitHub runner |
The npm hook is the one to watch if you work with Node. Samples put it in postinstall, and a few in build or test. A postinstall hook runs on any npm install, so a developer who pulls the repo and installs dependencies runs the stealer on their own laptop.
“A stolen GitHub token is only as dangerous as the workflows behind it. wfx does not exploit GitHub. It uses the access the repo already granted itself.”
What the payload takes
The script is not obfuscated. It is long, heavily commented and labels itself wfx in its own logs and variable names. It works in four stages.
1. Collect
- Every repository secret and variable the job can see, plus the GITHUB_TOKEN and the OIDC and Actions runtime tokens.
- The full environment, and any variable whose name contains words like token, secret, pass, cred, aws, azure, gcp, openai, anthropic or stripe.
- Credential files for AWS, Azure, GCP, Docker, Kubernetes, npm, PyPI, Git, SSH, GPG, Vault and Terraform.
- Keys for AI providers and coding tools, including OpenAI, Anthropic, Gemini, Hugging Face, Cursor and Copilot.
- Shell histories, plus any .env, .pem, .p12, .jks, .kdbx and kubeconfig files under the home directory.
- The memory of the runner's own processes (Runner.Worker and Runner.Listener), read through /proc. This is how it finds secrets that were masked in logs.
- On self-hosted runners: cloud instance metadata credentials, Docker state and systemd units.
- A snapshot of the repo, the last 100 commits and recent artifacts.
The injected step the attacker adds to an existing workflow is named "Restore cache" followed by four random characters. Its entire job is to hand the whole secret store to the script:
WF_SECRETS = ${{ toJSON(secrets) }}
WF_VARS = ${{ toJSON(vars) }}
WF_CALLBACK = (attacker collection server)
WF_CTOKEN = (per-campaign callback token)toJSON(secrets) is the whole trick. A workflow normally references one secret at a time. This dumps all of them in a single expression, which almost no legitimate workflow needs.
2. Escalate
The script asks GitHub for OIDC tokens and tries them against AWS, GCP and Azure. If the repo has a cloud role that trusts it, the attacker is now in your cloud account with the role's permissions. From there it tries to go deeper: AWS role chaining and iam:CreateAccessKey, GCP service account impersonation, replay of cached Azure CLI refresh tokens, and logins to Vault and Kubernetes from the GitHub identity.
3. Persist
- AWS: rewrites a role's trust policy so the principal becomes "AWS": "*", which lets any AWS account assume it. Sessions are named wfx-followed-by-a-number.
- Azure: adds a federated credential named wfx-ci-followed-by-a-number to an Entra app registration, pointing back at the compromised repo.
- Branch protection: code to post forged success statuses for required checks. The samples reported so far did not use it, but it is in the script.
4. Exfiltrate
Everything is packed into one gzipped archive. The script has three delivery channels that live inside GitHub itself (the Actions cache, the job summary and an artifact named wfx-loot plus the run ID). In every sample reported so far those were switched off. Data left by HTTP POST to port 8080 on three IP addresses, and from the early hours of 12 November, increasingly through Telegram.
Do not take comfort from a clean look. The GitHub-side channels are disabled, so a repo with no wfx-loot artifacts, no wfx- cache entries and no WFX-BEGIN job summaries can still have lost everything.
The part that outlives key rotation
Most incident response playbooks for leaked CI secrets say: rotate everything. For wfx that is necessary and not sufficient.
The Azure federated credential does not contain a secret. It is a trust rule that says: tokens issued to this repo may sign in as this app. Rotating the app's client secret does nothing to it. The AWS trust policy edit works the same way: it is a permission, not a key. And an access key created with iam:CreateAccessKey is a brand new credential that was never in your secret store, so there was nothing to rotate. You have to look for each of these in the cloud account, not just in GitHub.
Timeline
- 10 November, about 12:45 UTC: probing begins. Within the first day, tens of thousands of probe PRs appear.
- 11 November, about 09:17 UTC: one observed repo gets the payload commit and a revert 12 seconds later from a bot account. The run in between is deleted.
- 11 November, 12:11 UTC: two "chore: add ci helper" commits land under the WebdriverIO bot account, which was allowed to bypass branch protection. A revert pull request follows at 12:30. That bot pushed 56 commits across 28 repos.
- 12 November, 01:10 UTC: the first payload copy that can send to both HTTP and Telegram appears.
- 12 November, about 05:30 UTC: copies that send only to Telegram start to appear. Every copy sampled between 07:00 and 07:40 UTC used Telegram only.
- 12 November, 09:17 UTC: probe PRs on GitHub total 64,865, with 58,543 still open.
The Telegram switch matters for defenders. A blocklist made of the three IP addresses stops the early copies and misses the newer ones. You also need to watch for api.telegram.org from build runners.
Affected packages
The attacker is after CI secrets, not downstream installs, so package impact is small next to the repo count. Where the payload landed in a package's own repo and a release was cut, these are the versions reported:
| Package | Ecosystem | Affected versions | Notes |
|---|---|---|---|
| digitalelvis/neuronai-studio | Packagist | 3.14.3 | Pin to 3.14.2 or earlier. Treat any machine that installed or opened 3.14.3 as compromised. |
| github.com/onuragtas/docker-env | Go | v1.0.278, v1.0.280 | Payload is in a workflow file only. Go source and release binaries are unchanged. Remove the versions anyway. |
| github.com/vkuznet/gocurl | Go | v0.0.0-20261111095532-8d5aad0cb0da | Workflow file only. The malicious commit was reported as not reverted. |
Check composer.lock and go.sum for these. Do not read the short list as the whole story: at least 50 hijacked accounts publish packages across npm, Go, Packagist, PyPI and crates, and a package built from a repo between the payload commit and its revert may carry more than the source suggests.
Indicators of compromise
| Type | Indicator | Note |
|---|---|---|
| Network | 194.150.166[.]244:8080/exfil | Collection server |
| Network | 18.143.168[.]96:8080/exfil | Collection server |
| Network | 153.75.82[.]159:8080/exfil | Collection server, reported by two independent teams |
| Network | api.telegram.org sendDocument from a runner | Newer copies, from 12 Nov |
| Pull request | docs: dependency audit notes | From branch wfx-docs, adds docs/wfx-audit.md |
| Branch | wfx-docs, chore/[a-z0-9]{8}, ci-check-[0-9]{4} | Probe and throwaway branches |
| Commit message | ci: refresh build cache, ci: restore, ci: cleanup, chore: add ci helper, chore: add dependency audit workflow | Payload and revert commits |
| File | .ci-[a-z0-9]{6}.sh, .github/workflows/build-audit-[a-z0-9]{6}.yml | Hidden script and new workflow |
| Workflow step | Restore cache plus 4 characters, Pass runtime, Run audit | Injected into existing workflows |
| Cloud | Azure federated credential wfx-ci-N, AWS session wfx-N, AWS trust policy with Principal AWS * | Persistence |
| Script hash | 89695abb392d5cfd4972672961e644bb46baa98aa1ed7173c2a2816c762735cf (SHA-256) | One reported sample. Each copy embeds its target repo, so hashes differ. |
Check your own exposure in ten minutes
Start with the repo, then the cloud accounts. These are read-only. Replace OWNER, REPO and ci.yml with your own.
# 1. Probe PRs and leftover branches
gh search prs --owner OWNER "docs: dependency audit notes" --match title
git ls-remote --heads origin 'wfx-docs' 'chore/*' 'ci-check-*'
# 2. Payload and revert commits since 10 Nov
git fetch --all --prune
git log --all --since=2026-11-10 --oneline \
--grep='ci: refresh build cache' --grep='chore: add ci helper' \
--grep='ci: restore' --grep='ci: cleanup' \
--grep='chore: add dependency audit workflow'
# 3. Workflows dumping the whole secret store, hidden scripts and hooks
grep -rn 'toJSON(secrets)' .github/workflows/
ls -a | grep '^\.ci-'
grep -n '\.ci-' package.json
# 4. Gaps in run numbers for one workflow (gaps can have innocent causes, so confirm)
gh api repos/OWNER/REPO/actions/workflows/ci.yml/runs --paginate \
--jq '.workflow_runs[].run_number' | sort -n \
| awk 'NR>1 && $1!=p+1 {print "gap after run", p} {p=$1}'# AWS: trust policy edits, new keys, wfx sessions, since the 10th
for e in UpdateAssumeRolePolicy CreateAccessKey AssumeRoleWithWebIdentity; do
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=$e \
--start-time 2026-11-10T00:00:00Z --output json \
| jq -r '.Events[] | [.EventTime,.EventName,.Username] | @tsv'
done
# AWS: any role that trusts every account
aws iam list-roles --output json | jq -r '.Roles[]
| select([.AssumeRolePolicyDocument.Statement] | flatten
| any(.Principal.AWS? == "*")) | .RoleName'
# Azure: federated credentials you did not create
az ad app federated-credential list --id <app-object-id> \
--query "[?starts_with(name,'wfx-ci')]"
# Network logs: the three IPs and Telegram from build runners since 10 Nov
# 194.150.166.244 18.143.168.96 153.75.82.159 api.telegram.orgAlso look at GCP for GenerateAccessToken and SignJwt calls coming from your GitHub workload identity pool, and at Vault for JWT logins from the GitHub OIDC issuer. If your org has audit log access, look for deleted workflow runs since 10 November.
If you were hit, do this in order
- Lock the account first. Revoke the commit author's personal access tokens, OAuth grants, SSH keys and sessions, reset the password and enforce phishing-resistant MFA. Bot accounts count too, and any bot allowed to bypass branch protection should lose that right until you have reviewed it.
- Scope the blast radius. For every repo with a payload commit, list every secret and variable its workflows could read, including environment and organisation secrets. Assume all of them are gone.
- Rotate all of it. Cloud keys, registry tokens (npm, PyPI, Packagist, crates), publishing tokens, AI provider keys and, for self-hosted runners, the host's own credentials.
- Remove what rotation cannot reach. Delete wfx-ci-* federated credentials in Entra, restore any AWS trust policy that lists "AWS": "*", delete access keys created since 10 November that you cannot account for, and review GCP impersonation and Vault and Kubernetes logins from the GitHub identity.
- Clean the repos. Delete the wfx-docs branch, close the probe PR, revert any payload that stayed, and remove .ci-* scripts and any postinstall hooks that call them.
- Rebuild what shipped. Republish any package version built between the payload commit and its revert from a clean commit, and block the bad versions listed above.
- Treat developer machines as exposed. Anyone who ran npm install on an affected commit may have run the hook locally, so rotate their credentials too.
Why scanners missed it
- There is no malicious package to flag. The payload lives in a workflow file or shell script inside the repo, so software composition analysis, which reads the dependency graph, sees nothing wrong.
- The commit is authored by a real person's real account with a valid token. Identity-based checks pass because the identity is genuine.
- It is plain text. There is no obfuscation for a heuristic to catch, and a long commented shell script looks like a lot of legitimate CI code.
- Hosted runners can usually reach any IP on any port. A POST to port 8080 is not blocked unless you set egress rules yourself.
- The evidence is removed fast. A revert and a deleted run within about a minute leave a clean default branch.
- Some pushes came from bot accounts that were allowed to bypass branch protection, so required reviews never happened.
What would have capped the damage
You cannot stop a token from being stolen from a developer's laptop. You can decide what a stolen token is worth.
- Set permissions on every workflow and job to the minimum it needs, as GitHub's docs recommend (for example contents: read), so a hijacked job has little to work with.
- Do not trust a whole repository from your cloud account. GitHub's OIDC docs show subjects scoped to an environment (repo:octo-org/octo-repo:environment:prod) or to a branch (repo:my-org/my-repo:ref:refs/heads/main). A throwaway branch cannot assume a role that only accepts main.
- Require code owner review for .github/workflows and remove branch protection bypass from bots.
- Use npm ci --ignore-scripts in CI and, where you can, on developer machines, so a postinstall hook does not run on its own.
- Alert on unexpected egress from runners, including api.telegram.org, and on workflows that reference toJSON(secrets).
Attribution, and how it maps to ATT&CK
wfx is unattributed. Some of the same compromised accounts and a few injected workflows overlap with a campaign tracked as GhostAction in October, including at least 100 repos where wfx was injected into GhostAction workflow files that still sent data to 193.32.204[.]199. Researchers have been careful to say that overlap does not prove one actor, because stolen tokens are shared and sold. Some newer copies carry Indonesian-language comments from a hasty Telegram edit, and that has not been seen in the earlier activity either.
- T1195.002 Compromise Software Supply Chain: tampering with source and build files before they reach consumers.
- T1552.001 Credentials In Files: harvesting credential files, .env files and kubeconfig from the runner and home directory.
Frequently asked questions
What is the wfx GitHub Actions secret stealer?
+
How do I know if my repository was hit?
+
Is rotating my secrets enough?
+
What if the attacker only opened the probe pull request?
+
Does wfx run on self-hosted runners?
+
Is wfx linked to the October GhostAction campaign?
+
Sources
- https://www.aikido.dev/blog/compromised-github-accounts-push-ci-secret-stealer-to-tens-of-thousands-of-repositories
- https://www.stepsecurity.io/blog/wfx-phantom-the-github-actions-secret-stealer
- https://www.endorlabs.com/learn/github-actions-supply-chain-attack-targeting-ci-secrets-at-scale
- https://safedep.io/blog/github-actions-wfx-credential-stealer
- https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication
- https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect
- https://attack.mitre.org/techniques/T1195/002/
- https://attack.mitre.org/techniques/T1552/001/