NIST deprecates RSA-2048 from 2030. Find every one of them before attackers do.
Home/Blog/wfx: Hijacked GitHub Accounts Are Pushing a CI Secret Stealer to Tens of Thousands of Repos
Threat ResearchNovember 12, 202613 min read

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.

R
Security researcher with multiple CVE credits, including CVE-2024-9145 in Wiz's VS Code extension. 3x Black Hat Arsenal presenter. Former Meta security engineer.
wfx: Hijacked GitHub Accounts Are Pushing a CI Secret Stealer to Tens of Thousands of Repos
Key takeaways
  • 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.

Key takeaway

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 countedFigureHow it was counted
Hijacked accounts that opened probe PRs4,837Earliest full tally
Repositories probed61,764Earliest 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 takenAt least 1,806Confirmed lower bound
Public repos with a payload commit on the default branch8,093GitHub search, 04:36 UTC, 12 Nov. Public default branches only
Commits titled "ci: refresh build cache"About 19,900Our own GitHub search, 09:17 UTC, 12 Nov
Commits titled "chore: add ci helper"About 2,600Our own GitHub search, 09:17 UTC, 12 Nov
Probe PRs into repos the hijacked account does not own10,431Reached through collaborator or org access
Hijacked accounts that publish packagesAt least 50npm, Go, Packagist, PyPI and crates
wfx scale, 12 November 2026. Counts move hourly.

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

  1. 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.
  2. Payload. The stealer is pushed in one of four forms (table below). The commit message looks like routine housekeeping.
  3. Run. The workflow executes on a GitHub runner, or the npm hook executes on whatever machine runs npm install.
  4. 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.
VariantCommit messagePublic repos (default branch, 04:36 UTC 12 Nov)Where it runs
Steps added to an existing workflowci: refresh build cacheAbout 7,300GitHub runner
Hidden .ci-xxxxxx.sh plus an npm hook in package.jsonchore: add ci helper771Runner and developer machines
New workflow on a throwaway branchchore: add dependency audit workflow16 (new workflow in an empty repo)GitHub runner
Appended to a shell script CI already runs, such as update.shVariesNot countedGitHub runner
The four delivery variants

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:

what the injected step sets
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.

Watch out

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

  1. 10 November, about 12:45 UTC: probing begins. Within the first day, tens of thousands of probe PRs appear.
  2. 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.
  3. 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.
  4. 12 November, 01:10 UTC: the first payload copy that can send to both HTTP and Telegram appears.
  5. 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.
  6. 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:

PackageEcosystemAffected versionsNotes
digitalelvis/neuronai-studioPackagist3.14.3Pin to 3.14.2 or earlier. Treat any machine that installed or opened 3.14.3 as compromised.
github.com/onuragtas/docker-envGov1.0.278, v1.0.280Payload is in a workflow file only. Go source and release binaries are unchanged. Remove the versions anyway.
github.com/vkuznet/gocurlGov0.0.0-20261111095532-8d5aad0cb0daWorkflow file only. The malicious commit was reported as not reverted.
Packages reported with the payload

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

TypeIndicatorNote
Network194.150.166[.]244:8080/exfilCollection server
Network18.143.168[.]96:8080/exfilCollection server
Network153.75.82[.]159:8080/exfilCollection server, reported by two independent teams
Networkapi.telegram.org sendDocument from a runnerNewer copies, from 12 Nov
Pull requestdocs: dependency audit notesFrom branch wfx-docs, adds docs/wfx-audit.md
Branchwfx-docs, chore/[a-z0-9]{8}, ci-check-[0-9]{4}Probe and throwaway branches
Commit messageci: refresh build cache, ci: restore, ci: cleanup, chore: add ci helper, chore: add dependency audit workflowPayload and revert commits
File.ci-[a-z0-9]{6}.sh, .github/workflows/build-audit-[a-z0-9]{6}.ymlHidden script and new workflow
Workflow stepRestore cache plus 4 characters, Pass runtime, Run auditInjected into existing workflows
CloudAzure federated credential wfx-ci-N, AWS session wfx-N, AWS trust policy with Principal AWS *Persistence
Script hash89695abb392d5cfd4972672961e644bb46baa98aa1ed7173c2a2816c762735cf (SHA-256)One reported sample. Each copy embeds its target repo, so hashes differ.
wfx indicators

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.

check-repo.sh
# 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}'
check-cloud.sh
# 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.org

Also 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

Frequently asked questions

What is the wfx GitHub Actions secret stealer?

+
wfx is a CI secret stealer that attackers have pushed into GitHub repositories since 10 November 2026 using stolen GitHub credentials. A roughly 1,500-line shell script runs in GitHub Actions, collects secrets and cloud credentials, tries to use GitHub OIDC to reach AWS, GCP and Azure, then deletes its own run.

How do I know if my repository was hit?

+
Search for a pull request titled "docs: dependency audit notes", a branch named wfx-docs, and commits such as "ci: refresh build cache" or "chore: add ci helper" since 10 November. Also check the Actions tab for a missing run number right after one of those commits, because the attacker deletes the run.

Is rotating my secrets enough?

+
No. The payload can leave an AWS trust policy that trusts any account, new AWS access keys, and an Azure federated credential named wfx-ci. None of these are in your secret store, so rotating secrets does not remove them. Search each cloud account for them directly.

What if the attacker only opened the probe pull request?

+
The probe means the account that opened it has a working login for your repo, so treat that account as compromised. Check for payload commits and missing run numbers. If you find none, lock the account and review its access, but you may not have lost secrets.

Does wfx run on self-hosted runners?

+
Yes, and it takes more there. Besides secrets and tokens it collects cloud instance metadata credentials, Docker state, the runner's RSA parameters and systemd units. Treat the runner host as compromised, rotate its credentials and rebuild it, since the script reads process memory on the machine.

Is wfx linked to the October GhostAction campaign?

+
It overlaps but is not proven to be the same actor. Some compromised accounts and workflow files appear in both, and wfx was injected into more than 100 repos' GhostAction workflows. Stolen tokens get shared and sold, so researchers treat the two as separate and wfx as unattributed.

See your full attack chain.
Code, build, runtime. One platform.