wfx-loot stole CI secrets from tens of thousands of repos. GitHub held the workflow forms, but an npm hook slipped past the hold and ran.

TL;DR

wfx-loot is a GitHub Actions secret stealer pushed into public repositories with stolen GitHub credentials. It ran in two phases: a small precursor from Oct. 24 to 29, 2026, and a mass wave that started on Nov. 10 and was still running on Nov. 12.

  • Scale. GitHub search on Nov. 12 returned 66,001 probe pull requests titled docs: dependency audit notes since Nov. 10, and 28,148 ci: refresh build cache commits on default branches between Nov. 10 and 12. Our own dataset holds 28,500 payload patches from 2,367 accounts across 17,715 repositories.
  • One payload, four delivery forms. The same shell stealer ships injected into an existing workflow, as a throwaway workflow on a side branch, as a hidden script run by an npm hook, or appended to an existing shell script.
  • GitHub held the workflow-file forms. Every November payload run we could still observe was put in action_required and never started.
  • The npm hook bypassed the hold. It changes no workflow file. The repository's own unchanged workflows run npm install, which runs the hidden script. We have public job logs from a sample of 19 repositories where the script executed, among which 18 where it reported a successful exfiltration.
  • The actor moved to the hook when the holds thwarted the original campaign. Hook commits went from 10 to 70 per three hours to 838 on the morning of Nov. 11, and to 1,210 on the morning of Nov. 12.

If a repository ran this payload, treat every secret reachable from that job as leaked, including secrets the workflow never referenced.

A familiar technique, pushed past its usual limit

Injecting a malicious workflow into a repository you can write to is not new. It is the core of GhostAction, which we tracked from 2025 into October 2026, and of the Shai-Hulud family. In the recent campaigns, GitHub has limited the impact by requiring explicit approval before running the malicious workflows.

wfx-loot is interesting because it solves that problem. The operator shipped the same stealer in four different wrappers, some of which do not touch workflow files. It hides the payload in a plain shell script, or appends it to an existing script, and calls that script from an npm lifecycle hook. The repository's own workflows, unchanged and already trusted, then run the payload during a normal npm install. GitHub's hold never applies, which we validated by observing the public job logs.

This post walks through the campaign timeline, the four delivery forms, the hold and the bypass, what the payload steals and does with the credentials, and what to do if you were hit. All times are UTC.

Timeline

The campaign started on Oct. 24 with a precursor that already carried all four delivery forms. This first phase then went quiet for 12 days before the mass wave on Nov. 10.

When (UTC)Event
Oct. 24, 00:05First payload commit (v1), a throwaway workflow. C2 45[.]61[.]182[.]54:8080/exfil
Oct. 24–29v1 precursor: 80 payload patches, 7 accounts, 12 repositories, all four delivery forms. The C2 moves to port 8081
Oct. 25, 21:56First npm-hook commit: the script is prepended to an existing verify script in package.json
Oct. 30–Nov. 9No payload commits
Nov. 10, 12:45Mass wave starts: v2a payload appears. C2 18[.]143[.]168[.]96:8080/exfil
Nov. 10, 16:35v2b body replaces v2a (+71/−20 lines), same C2
Nov. 11, 05:02First commit sending to 194[.]150[.]166[.]244:8080/exfil, same token and body
Nov. 11, 09:00–12:00npm-hook commits jump to 838 per three hours, up from 10 to 70 before
Nov. 11, 15:00–24:00No campaign commits on default branches
Nov. 12, 01:07v3: Telegram delivery added, C2 153[.]75[.]82[.]159:8080/exfil as the fallback
Nov. 12, 01:48v3 Telegram-only. The HTTP callback is set to 127.0.0.1:1/x, so Telegram is the only channel
Nov. 12, 06:00–09:00Second npm-hook burst: 1,210 commits per three hours

One payload, four delivery forms

In this campaign, each compromise starts with a probe. The actor opens a pull request titled docs: dependency audit notes from a wfx-docs branch, adding docs/wfx-audit.md. It carries no payload.

The payload itself ships in four wrappers. Inside one version, the forms differ only by the wrapper and its config.

FormWhereHow the payload starts
Throwaway workflownew .github/workflows/build-audit-xxxxxx.yml on a chore/xxxxxxxx branchits own on: push workflow, which asks for id-token: write (OIDC) and status: write
Workflow injectionan existing workflow on the default branchan added step named Restore cache xxxx, after a Pass runtime step
npm hook.ci-xxxxxx.sh at the root plus an edited package.jsonan npm script, generally postinstall
Script injectionappended to an existing shell script (start.sh, deploy.sh)an existing workflow calls the script

The throwaway workflow is the most common form in our data. However, the npm-hook wrapper is the one that matters in this campaign, as it allows bypassing GitHub detection of malicious workflow files.

In November, package.json gets this line, either as a new postinstall or prepended to an existing script such as build:

sh ./.ci-xxxxxx.sh >/dev/null 2>&1; _r=$?; if [ "$_r" -eq 42 ]; then exit 42; fi

The payload exits with 42 only when every exfiltration channel fails. So a failed theft also fails the install or build. We come back to that below.

Four versions of the stealer

VersionCommit timeBodyC2 and exfiltration
v1Oct. 24–29~400 lines45[.]61[.]182[.]54:8080 then :8081
v2aNov. 10, 12:45–15:00~1,210 lines (+820): GCP impersonation, AI-tool credential files18[.]143[.]168[.]96:8080
v2bNov. 10, 16:35–Nov. 11, 13:50~1,255 lines18[.]143[.]168[.]96:8080, then 194[.]150[.]166[.]244:8080 from Nov. 11, 05:02
v3Nov. 12, 01:07–~1,310 lines: Telegram delivery153[.]75[.]82[.]159:8080 plus Telegram, then Telegram only from 01:48

v1 already held the core of the stealer: secrets and variables, process environments, runner memory scan, credential files, self-hosted runner collection, OIDC minting, an encrypted fallback commit, and the exit-42 contract. v2a added AWS role chaining and persistence, Vault and Kubernetes token replay, the Azure refresh-token and federated-credential pivots, GCP signJwt, and AI-tool credential files. v3 added Telegram.

The hold, and the bypass

GitHub held the payload whenever it sat in a workflow file. The npm hook carries it in a plain script that the repository's own unchanged workflows run. Those runs were not blocked by GitHub as the workflow definition itself is legitimate and benign.

Workflow-file forms: held, then deleted

We sampled 264 payload commits, up to eight per hour from Oct. 24 to Nov. 12. For each one, we looked up the run of the modified workflow, the run numbers around it, and the check suites left on the commit.

  • Every November payload run still visible was held: seven of seven. Each had conclusion action_required. The earliest is from the first hour of the wave, Nov. 10, 12:45. One example that is still live is qway-tech/qway-academy-dados run #8, a v3 Telegram-only payload from Nov. 12, 06:01.
  • The actor deletes most payload runs. Of 65 November injection commits, 37 have a gap in the workflow's run numbers right at the payload commit.
  • Throwaway workflows leave almost nothing. Of 158 sampled workflows, most have no run or workflow record left.

A gap in run numbers proves a run was created. It does not prove the run executed. Our observations show no trace of workflow execution when the payload was directly embedded in a workflow file.

npm hook: not held, and it ran

The hook version only pushes changes to package.json and a root script. It does not change any workflow file. Therefore, the existing workflows run as usual, and npm install, npm ci, or pnpm install runs the script.

From a sample of 30 repositories that ran jobs on the hook commit, none was blocked by GitHub. When the actor did not delete the runs, their logs are still public and show the script executing:

09:47:30  Run npm ci
09:47:33  > mars-command-web@0.2.0 postinstall
09:47:33  > sh ./.ci-xgketh.sh >/dev/null 2>&1; _r=$?; if [ "$_r" -eq 42 ]; then exit 42; fi
09:47:48  added 99 packages, and audited 100 packages in 19s

Most of those jobs completed with exit code 0, meaning at least one exfiltration channel worked. Public examples include Mars-Command/Website, EliasAfara/algorithmic-odyssey, and HIVE-UofT/lab-website. In ArtAndrew/agent-operations the script ran in CI and again inside the Docker build.

In the few jobs that did not report success, the workflow exited with status code 42 as designed. This means a failed theft turns into a CI failure that is visible in the logs.

It is worth noting that embedding the payload in a script rather than the workflow file allows targeting more than the CI runners. Because the hook also runs outside CI, any developer who runs npm install from an affected repository between the hook and the cleanup commits ran the payload on their own machine. The window of opportunity for this scenario is, however, rather short due to the fast clean-up commits.

The actor moved to the hook

As GitHub prevented the execution of the malicious workflows, the operator leaned on the form that was not held. On default branches, we can observe the number of hook commits rising sharply while the workflow injections continued.

Three hours from (UTC)ci: refresh build cachechore: add ci helper
Nov. 10, 12:00–Nov. 11, 06:00 (6 slots)639–1,615 per slot10–24 per slot
Nov. 11, 06:004,05070
Nov. 11, 09:001,188838
Nov. 11, 12:001,415595
Nov. 11, 15:00–24:0000
Nov. 12, 00:002,30826
Nov. 12, 03:004,48177
Nov. 12, 06:001,4161,210
Nov. 12, 09:001,193344

What the payload steals

The payload is a POSIX shell script with an embedded Python memory scanner. It collects every credential it can reach into a staging folder ($RUNNER_TEMP/.wfx-<pid>), uses some of them to mint more, packs everything into one gzip archive, and sends it out. The analysis below is based on the v3 samples, compared against v1 and v2.

SourceWhat is takenWhen it applies
Workflow contextevery repository and organization secret and variable, through WF_SECRETS: ${{ toJSON(secrets) }} and WF_VARS: ${{ toJSON(vars) }}, plus the job's GITHUB_TOKENworkflow injection and throwaway workflow only
Process environmentsthe step's environment, and /proc/*/environ and /proc/*/cmdline of every process it can readevery form
AI provider keysenvironment variables matching about 90 provider prefixes (OpenAI, Anthropic, Gemini, Bedrock, Hugging Face, Mistral, OpenRouter, Cursor)every form
Runner memoryregex scan of readable memory of every process, Runner.Worker and Runner.Listener first, as root through sudo -n when availableevery form, when python3 exists
Credential files90+ fixed paths plus a home-directory searchevery form
Self-hosted runnercloud metadata credentials, docker inspect of running containers, the runner's own .credentials and RSA key, _diag logs, systemd units, secrets in persistent work foldersonly when RUNNER_ENVIRONMENT=self-hosted
Repositoryan archive of the default branch keeping up to 60 files matching 92 secret patterns, the full JSON of the last 100 commits, the artifacts of the last 10 runswhen a GITHUB_TOKEN is available
OIDCID tokens minted for the AWS, Azure, and GCP audienceswhen the job has id-token: write

The credential-file list is long. It covers AWS, Azure, gcloud, Docker, kubeconfig, .netrc, .git-credentials, gh and glab configs, package registry credentials for npm, Yarn, PyPI, Cargo, Maven, Gradle, NuGet, and more, plus Vault, Terraform Cloud, databases, and the whole ~/.ssh and ~/.gnupg. On top of that, it grabs AI coding-tool credentials (Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot, Aider, Ollama) and searches the home folder for .env*, *.pem, *.key, SSH private keys, *.p12, *.jks, *.kdbx, and kubeconfig.

The memory scan is narrower. It matches eight token families only: JWTs, GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_), GitLab glpat-, AWS AKIA keys, Anthropic sk-ant-, OpenAI sk-proj- and sk-svcacct-, and Hugging Face hf_. A password or a key of another format in memory is not caught by this scan, though it can still be picked up from a process environment or a file.

What it does with the credentials

The payload is capable of using short-lived tokens to reach cloud accounts and tries to leave long-lived access behind. It does so using the id-token: write permission when OIDC trust relationships are configured in the CI/CD.

  • AWS: it exchanges the OIDC token for the role found in the environment or the repository, chains a second AssumeRole with session tags, lists IAM users and calls iam:CreateAccessKey for up to two of them, and tries to rewrite the chained role's trust policy.
  • GCP: it turns ADC files, inline keys, or environment tokens into an access token, lists projects, then impersonates service accounts with generateAccessToken and signJwt.
  • Azure: it gets an ARM token, lists subscriptions and Key Vaults, replays refresh tokens from Azure CLI caches to reach Microsoft Graph, and adds federated identity credentials to applications so the compromised repository can sign in as those applications later.
  • Vault and Kubernetes: it replays the OIDC token for a Vault JWT login, then sends it as a bearer token to the Kubernetes API and tries to list secrets.

How it sends the loot out

The channels are tried in order. Exit code 0 means at least one reported success. Exit 42 means all failed.

ChannelHowStatus in our samples
TelegramsendDocument to one chat through 5 bot tokens tried in turnv3 only, primary channel from Nov. 12
HTTP C2POST to WF_CALLBACK, then WF_CALLBACK2v1 to v3, set to a dead address in v3 Telegram-only
Actions cachecache entry read back later with the stolen tokenon, but gated on private repositories
Step summarybase64 between <!--WFX-BEGIN--> and <!--WFX-END--> markerson, but gated on private repositories
Artifactartifact wfx-loot-<run id>-<job>-<pid>on, but gated on private repositories
Commit to a side branchAES-256 encrypted file on a ci-* branchoff (WF_FALLBACK=0, no key)
Run logbase32 between log markersoff (WF_EXFIL_LOG=0)

The in-GitHub channels (cache, summary, artifact) are switched on in every v2 and v3 sample, but gated on WF_PRIVATE=1, which is unset in all public samples. The payload's own comment explains why: on a public repository, anyone can read those drops. On a private repository, they would keep the loot inside GitHub, with no traffic to an outside address, for the actor to collect later with the stolen token.

Stolen credentials rarely have a single owner

431 of the accounts used in the wfx campaign, about 20%, were already compromised in the GhostAction wave. In several wfx repositories, the GhostAction security-audit.yml workflow still ran successfully on the wfx commits, so it may still be sending secrets to the GhostAction C2.

We saw the same pattern in GhostAction, where victim accounts were also used for unrelated cryptomining. The most likely explanation is the same here: the GitHub credentials used to push wfx-loot are not exclusive to its operator. Leaked tokens, infostealer logs, and credential dumps are traded and reused. One compromised account can serve several unrelated actors. As StepSecurity notes, this overlap does not prove a single actor.

For victims, the consequence is the one we keep repeating. Rotating the secrets the payload stole is not enough. The GitHub credential that allowed the push in the first place has to be found and revoked too, or the next actor will use it again.

What to do if you were hit

1. Decide whether the payload ran.

  • npm hook: look for a run on the hook commit, where npm, pnpm, or yarn installs at the repository root without --ignore-scripts. A workflow run log line > sh ./.ci-xxxxxx.sh means the payload ran. A successful step means it reported a delivery. Check developer machines that installed dependencies in that window too.
  • Workflow forms: a gap in run numbers proves a run was created. Check your organization audit log to verify if the run was approved and executed. In doubt, consider the malicious payload ran.

2. If it ran, rotate everything the job could reach. That means all repository and organization secrets and variables, not only those the workflow references. In the hook form, it also means any secret in the runner's environment or memory, credentials in runner files, and secrets in the repository and its last 100 commits.

3. Also look for what the payload creates.

  • AWS: IAM access keys created since Nov. 10, role trust policies changed since Nov. 10, sessions named wfx-*.
  • Azure: federated identity credentials named wfx-ci-* on applications, refresh-token use from unexpected clients.
  • GCP: generateAccessToken and signJwt calls on service accounts.
  • Vault and Kubernetes: Vault JWT logins and Kubernetes secret reads by GitHub OIDC identities.
  • GitHub: commit statuses with description wfx, and branches ci-<run>-<job>-<pid>, chore/xxxxxxxx, and wfx-docs.

4. Revoke the compromised account's access. Personal access tokens, OAuth and GitHub App grants (StepSecurity tied some tokens to the Fly.io GitHub App), and SSH keys. Check any bot account allowed to bypass branch protection.

Indicators of compromise

TypeValue
C2 (v1, Oct.)45[.]61[.]182[.]54:8080/exfil, 45[.]61[.]182[.]54:8081/exfil
C2 (v2)18[.]143[.]168[.]96:8080/exfil, 194[.]150[.]166[.]244:8080/exfil
C2 (v3)153[.]75[.]82[.]159:8080/exfil
Callback tokens (t=)b42d70a7aab9140ca19e8249, 15f44heehnax8jdf41yx
Telegramapi.telegram.org sendDocument, chat -1004340163187, 5 bot tokens (withheld)
ProbePR docs: dependency audit notes, branch wfx-docs, file docs/wfx-audit.md
Commit messagesci: refresh build cache, ci: restore, ci: cleanup, chore: add ci helper, chore: add dependency audit workflow, ci: cache
Files.ci-[a-z0-9]{6}.sh, .github/workflows/build-audit-[a-z0-9]{6}.yml, .ci/<time>-<pid>.bin
npm scriptsh ./.ci-xxxxxx.sh >/dev/null 2>&1; _r=$?; if [ "$_r" -eq 42 ]; then exit 42; fi
Workflow stepsPass runtime, Restore cache <4 chars>, workflow name Dependency Audit <4 chars>
Runner artifactsstaging folder $RUNNER_TEMP/.wfx-<pid>, scan folder /tmp/wfx-scan-<pid>, cache keys wfx-*, artifacts wfx-loot-*, summary marker <!--WFX-BEGIN-->
VariablesWF_SECRETS, WF_VARS, WF_CALLBACK, WF_CTOKEN, WF_TG_BOTS, WF_TG_CHAT, WF_EXFIL_*, WF_PRIVATE, WF_RC_<n>
Log notesmemscan_files, archive_bytes, exfil_no_runtime_vars, deliver_retry, tg_dropped

FAQ

Did wfx-loot actually exfiltrate secrets, or was it all held?

Both outcomes happened. The workflow-file forms were held in every November run we could still see. The npm-hook form was not held, and public job logs from 19 repositories show the script running, with 18 reporting a successful delivery.

I only use a few secrets in my workflow. Do I need to rotate the rest?

Yes. When the payload runs inside a workflow, it reads toJSON(secrets) and toJSON(vars), which return every repository and organization secret and variable, not only the ones the workflow references. Treat all of them as leaked.

We run on self-hosted runners. Does that change anything?

It makes things worse. On a self-hosted runner, the payload also collects cloud metadata credentials, the runner's own credentials and RSA key, container secrets, and secrets left in persistent work folders.

How GitGuardian can help

We found this campaign through GitGuardian Public Monitoring, the same dataset we used for GhostAction and the Shai-Hulud waves. If you operate CI at scale, the harder problem after rotation is knowing which machine identities and tokens a compromised job could even reach. NHI Governance maps those identities and their access so you can cut what a leaked token is allowed to do, not just rotate it.