Back to Blog

WFX Phantom: The GitHub Actions Secret Stealer That Deletes Its Tracks

A coordinated botnet of compromised GitHub accounts launched a widespread attack, opening seemingly harmless pull requests before pushing a malicious shell script directly to default branches. The script, dubbed **wfx**, executed through GitHub Actions to steal secrets before the attackers quickly reverted their changes, leaving minimal traces.
Owais Shaikh
View LinkedIn

November 12, 2026

Share on X
Share on X
Share on LinkedIn
Share on Facebook
Follow our RSS feed
Table of Contents

Starting on November 10, 2026, an attacker has been using stolen GitHub tokens to push a CI secret stealer into every repository those tokens can write to. The stealer names itself wfx. It collects every GitHub Actions secret, the runner's memory, and cloud credentials, and it tries to stay in your AWS and Azure accounts after you rotate. Then the attacker reverts the commit and deletes the workflow run, usually within a minute.

The scale is large. As of November 12, GitHub search returns more than 62,000 of the attacker's probe pull requests, about 90% of them still open, and new ones are still appearing every few minutes.

We pulled more than 80 of the attacker's commits from GitHub to study the payload, including commits to nextcloud/docker, webdriverio/webdriverio, juejin-cn/repo-data and ipfs-shipyard/js-ipld-fixtures. This post covers what we found, including two details that change how you detect it:

  • A switch to Telegram. Since about 05:30 UTC on November 12, new copies of the stealer send stolen data to a Telegram chat and carry no collection IP at all. Blocking the published IP addresses no longer stops it.
  • A fourth hiding place. Besides workflows and a hidden .ci-*.sh file, the attacker also pastes the stealer into ordinary shell scripts that CI already runs, such as update.sh or tests/retry_env.sh.

Check your repositories now

Look for a pull request titled docs: dependency audit notes from a wfx-docs branch, or commits titled ci: refresh build cache, chore: add ci helper or chore: add dependency audit workflow, on or after November 10. Then look for a missing run number in the Actions tab right after that commit. A missing run means the stealer probably ran and the attacker deleted the evidence. Treat every secret and cloud role that workflow could reach as stolen.

How the Attack Works

The attacker does not exploit a bug in GitHub Actions. It has valid tokens for real developer and bot accounts, and it uses them the way a developer would: it opens pull requests and pushes commits. For each repository a token can write to, it runs the same three steps.

1. Probe: a harmless pull request

The attacker opens a pull request titled docs: dependency audit notes, with the body Routine dependency audit notes, from a branch named wfx-docs. It adds one file, docs/wfx-audit.md, with two lines of text. The file is byte-identical in every pull request we checked.

The pull request does nothing by itself. It tells the attacker that the token can write to the repository. If you see one, the account that opened it is compromised, even if nothing else happened.

GitHub search for the probe pull request on November 12: about 63,000 results, with new ones opened minutes earlier.

2. Payload: one stealer, four hiding places

Next the attacker pushes the stealer, a single shell script of about 1,500 lines, in one of these forms:

Commit message What it changes Example
ci: refresh build cache Adds two steps to an existing workflow such as ci.yml or tests.yml: a Pass runtime step, then a Restore cache <4 characters> step that holds the whole script. It also adds statuses: write to the workflow's permissions. drunomics/nuxtjs-drupal-ce
ci: refresh build cache Appends the script to an existing shell script that a workflow already runs. nextcloud/docker (update.sh), PancrePal-xiaoyibao/MinerU-xyb (tests/retry_env.sh)
chore: add dependency audit workflow Adds a new workflow, .github/workflows/build-audit-<6 characters>.yml, named Dependency Audit <4 characters>, on a throwaway chore/<8 characters> branch. Commit search
chore: add ci helper Two commits. The first adds a hidden script, .ci-<6 characters>.sh, at the repository root. The second edits package.json so that an npm script runs it first. In our samples the hooked script was postinstall 11 times, build twice and test once. zerosoul/rc-bullets, juejin-cn/repo-data, ipfs-shipyard/js-ipld-fixtures

The first form is by far the most common: GitHub commit search returns more than 21,000 commits with the message ci: refresh build cache between November 10 and 12.

In nextcloud/docker, the stealer was appended to update.sh, a script the repository's own workflow runs. Line 211 sets the collection server.
The workflow form in drunomics/nuxtjs-drupal-ce: a Pass runtime step copies the Actions runtime token into later steps, then Restore cache x9xx runs the stealer
The hook form in zerosoul/rc-bullets: a postinstall script runs the hidden .ci-7arkno.sh. The attacker's tool also rewrote the Chinese keywords as \uXXXX escapes when it re-saved the file.

3. Cleanup: revert and delete the run

As soon as the workflow starts, the attacker pushes a revert (ci: restore, sometimes followed by ci: cleanup) and deletes the workflow run with its logs.

  • drunomics/nuxtjs-drupal-ce. At 09:17:23 UTC on November 11, the stealer was committed to the default branch. A ci: restore commit followed 12 seconds later, pushed by the project's bot account, drubot. The Actions history goes from run #1963 straight to run #1965. Run #1964, the run for the injected commit, is gone.
  • zerosoul/rc-bullets (595 stars). The repository's Pages deploy runs npm install on every push, which fires the hook. Run #62 and run #64 are there; run #63, triggered by the hook commit, is gone. The revert landed two minutes later.
The only trace left in the Actions tab: run #1964 is missing between #1963 and the attacker's ci: restore run #1965. Annotation added by StepSecurity.

In webdriverio/webdriverio, the two chore: add ci helper commits were pushed as the project's bot account, which was allowed to bypass branch protection. A contributor spotted them within 20 minutes and opened a revert. In both drunomics and WebdriverIO, the token the attacker used belonged to a bot. Automation accounts with write access, and especially with bypass rights, are as valuable to this attacker as maintainers.

WebdriverIO's revert of the two commits pushed as wdio-bot.

When the cleanup fails: what a surviving run shows

The attacker does not always manage to delete the run. In nextcloud/docker (7,400 stars), the stealer appended to update.sh ran in the repository's update.sh workflow (run #2675) three seconds after the commit, and the run was never deleted. A Nextcloud developer reverted the commit 27 minutes later. Because the run survived, its public log shows what the stealer reports about itself:

updating 32.0.15 [32] fpm-alpine
memscan_files 3
gcp_gcloud_auth_empty
gcp_live token=0 projects=0 gcloud=0
selfhosted_collected
az_graph_token_fail
oidc_discovery arn=none gcp=none azure=none
archive_bytes 8971
exfil_no_runtime_vars

The job ran on a self-hosted runner, so the stealer also collected host-level data (selfhosted_collected). It dumped runner process memory (memscan_files 3) and packed 8,971 bytes. The script prints deliver_retry after every failed upload, and none appears, so the first upload to 194.150.166[.]244 succeeded. The job then finished green, with nothing visibly wrong.

What the Stealer Does

The script is plain, heavily commented shell, with feature switches set through WF_* variables. It works in four stages.

Collect

  • Every repository secret and variable, passed in as ${{ toJSON(secrets) }} and ${{ toJSON(vars) }}, plus the job's GITHUB_TOKEN and OIDC request URL.
  • The memory of the Runner.Worker and Runner.Listener processes, read through /proc/<pid>/mem by python3, with sudo when available. That is where the runner keeps decrypted secrets, including ones the workflow never referenced. This is why the script and hook forms are dangerous even though they never touch toJSON(secrets).
  • Every process environment, credential files for AWS, Azure, GCP, Docker, Kubernetes, npm, PyPI, SSH and Vault, API keys for AI providers and coding agents, and, on self-hosted runners, cloud instance metadata credentials.
  • A snapshot of the repository, its recent history and recent workflow artifacts.

Escalate

It exchanges the job's OIDC token for AWS, GCP and Azure sessions, chains into further AWS roles named in the repository, impersonates GCP service accounts, and replays cached Azure CLI refresh tokens to get Microsoft Graph and Key Vault access.

Persist

Three actions are designed to outlast credential rotation:

  • Forged required checks. Early in the run, the script posts success statuses under the names of the repository's required checks. Branch protection matches checks by name, so the commit can be merged while the stealer is still running. This is why the injected workflow adds statuses: write.
  • An AWS role open to everyone. It rewrites an assumed role's trust policy to "Principal": {"AWS": "*"} and tries iam:CreateAccessKey for IAM users, giving the attacker long-lived keys.
  • An Azure back door. It adds a federated identity credential named wfx-ci-<n> to Azure AD applications, trusting workflows in the compromised repository. Those workflows can then sign in to Azure with no stored secret at all.

Exfiltrate

Everything is packed into one gzipped archive. The code supports several delivery channels, including three that use GitHub's own infrastructure (the Actions cache, the step summary between <!--WFX-BEGIN--> markers, and an artifact named wfx-loot-<run id>), but those were switched off in every copy we examined. Delivery happened in two generations:

Generation First seen in our samples Where the data goes Copies we examined
HTTP November 10 HTTP POST to 194.150.166[.]244:8080/exfil or 18.143.168[.]96:8080/exfil 57
Telegram November 12, about 05:30 UTC Uploaded with sendDocument to api.telegram.org through five bot tokens, into chat -1004340163187. The HTTP callback is set to 127.0.0.1, a dead end. A comment in the script reads "no callback IP anywhere in the payload". 14

The switch to Telegram means blocklists built from the November 10 IP addresses miss the newest copies. An egress allowlist still stops both, because neither a bare IP nor api.telegram.org belongs in a normal build.

Affected Repositories and Searches

These GitHub searches show the campaign's footprint. Code search needs you to be signed in to GitHub.

A sample of public repositories where the stealer, carrying the 194.150.166[.]244 collection address, was committed. All have since been reverted; the links point to the attacker's commits.

Commit message What it changes Example
ci: refresh build cache Adds two steps to an existing workflow such as ci.yml or tests.yml: a Pass runtime step, then a Restore cache <4 characters> step that holds the whole script. It also adds statuses: write to the workflow's permissions. drunomics/nuxtjs-drupal-ce
ci: refresh build cache Appends the script to an existing shell script that a workflow already runs. nextcloud/docker (update.sh), PancrePal-xiaoyibao/MinerU-xyb (tests/retry_env.sh)
chore: add dependency audit workflow Adds a new workflow, .github/workflows/build-audit-<6 characters>.yml, named Dependency Audit <4 characters>, on a throwaway chore/<8 characters> branch. Commit search
chore: add ci helper Two commits. The first adds a hidden script, .ci-<6 characters>.sh, at the repository root. The second edits package.json so that an npm script runs it first. In our samples the hooked script was postinstall 11 times, build twice and test once. zerosoul/rc-bullets, juejin-cn/repo-data, ipfs-shipyard/js-ipld-fixtures

Code search also returns 19 files named SECURITY_INCIDENT_2026-11-10.md, all from one developer who committed an incident write-up to each affected repository after cleaning up.

Links to GhostAction and PolinRider

Some of the repositories hit in this wave were also hit in the October GhostAction wave. Three of our samples were injected into a security-audit.yml left behind by GhostAction, which still sent data to 193.32.204[.]199, the October wave's collection server.

One commit we pulled, a chore: add dependency audit workflow commit in Kellie-Brighty/Suinicate, also added a hidden VS Code task that runs a Node.js file when the folder is opened, a technique used by the PolinRider campaign. Neither overlap proves a single actor. Stolen tokens are often sold or shared, and several groups may be using the same pool. What it does show is that an account compromised once tends to be compromised again: if you were hit in October, check for this wave too.

Indicators of Compromise

Type Indicator
Collection servers 194.150.166[.]244:8080/exfil, 18.143.168[.]96:8080/exfil
Telegram api.telegram.org sendDocument; chat -1004340163187; bot IDs 8708988906, 9196217635, 9180059428, 8902935164, 8876515636
Pull request Title docs: dependency audit notes; branch wfx-docs; file docs/wfx-audit.md (git blob ee6e5addfe591923fc385553cb66cf0d25634a6b)
Commit messages ci: refresh build cache, ci: restore, ci: cleanup, chore: add ci helper, chore: add dependency audit workflow
Branches and files chore/[a-z0-9]{8}; .ci-[a-z0-9]{6}.sh; .github/workflows/build-audit-[a-z0-9]{6}.yml; any shell script or workflow containing the markers below
Script content Steps named Pass runtime and Restore cache <4 characters> or Run audit; WF_CALLBACK, WF_CTOKEN, WF_EXFIL_*, WF_TG_BOTS variables; staging folder .wfx-$$; log notes memscan_files, archive_bytes, deliver_retry
Cloud persistence Azure federated identity credentials named wfx-ci-*; AWS role trust policies with "Principal": {"AWS": "*"}

How StepSecurity Protects You

The attack has four forms and a cleanup step, so no single control covers all of it. StepSecurity layers four:

Control Workflow forms Script and hook forms What it does
Secret Exfiltration Policy Blocks n/a Cancels the run before toJSON(secrets) is read
Secret Exfiltration Attempt detection Detects n/a Flags the run, and raises it to High confidence when the run is deleted
Harden-Runner Blocks and detects Blocks and detects Blocks the upload and flags the runner memory scan
Malicious File Scan Finds Finds Finds the stealer in git history after the revert
Github Audit Log Investigates Investigates Tracks deleted workflow runs and suspicious PAT activity to reconstruct the attack after cleanup

1. Secret Exfiltration Policy: stop the toJSON(secrets) dump

Both workflow forms hand the stealer every repository secret through one line: WF_SECRETS: "${{ toJSON(secrets) }}". The Secret Exfiltration Policy, under Workflow Run Policies, cancels a run that tries this before the job starts.

  • Turn on Block only when a workflow accesses all secrets at once. This blocks bulk access such as ${{ toJSON(secrets) }} while leaving ordinary references such as ${{ secrets.NPM_TOKEN }} alone.
  • Turn on Analyze default branch runs. The chore: add dependency audit workflow form runs on a throwaway branch, which the policy covers by default. The ci: refresh build cache form is pushed straight to the default branch, so this option is what catches it.
  • Review your exemptions. The policy lets you exempt users and bot accounts. In this campaign, the stolen tokens included bot accounts, so an exempted bot is exactly the account an attacker would use.

The policy cannot help with the script and hook forms, because they leave the workflow file unchanged and read secrets from runner memory instead. That is what the next two layers are for.

A Secret Exfiltration Policy in Enforce mode with Bulk Secrets Only Mode enabled, which blocks bulk dumps such as toJSON(secrets).
A workflow pushed to a branch to dump secrets is cancelled by the StepSecurity app before the job can read them

2. Secret Exfiltration Attempt detection: deleted runs are a signal, not a gap

The attacker's cleanup is built to defeat anyone who reviews the Actions tab after the fact. StepSecurity analyzes each workflow run as its webhook arrives, so deleting the run afterwards does not erase what was already seen. The Secret Exfiltration Attempt detection flags runs that dump all secrets with toJSON(secrets), and it points to the exact job, step and line.

If the workflow run, the branch or the workflow file is deleted shortly after the run, the detection raises its confidence to High. Deleting the evidence is itself one of the strongest signs of an attack. That is precisely what wfx does to every run it can, so its cleanup works against it. Turn on Notify when a secret exfiltration attempt is detected in Notification Settings to get an alert the moment it happens.

Secret Exfiltration Attempt detections. The second run is raised to High confidence because its branch was deleted after the run, the same cleanup wfx performs. Test runs in a StepSecurity integration org

3. Harden-Runner: block the upload and catch the memory scan

Harden-Runner watches every outbound connection and process in a GitHub Actions job, whichever form delivered the stealer.

  • Block mode stops both delivery generations. With an egress allowlist, the POST to a bare IP and the upload to api.telegram.org are both blocked, because neither is an endpoint a build normally uses. In Nextcloud's run, that would have stopped the upload that succeeded.
  • Audit mode flags them. Both destinations show up as anomalous outbound calls that are not in the job's baseline.
  • The memory scan is caught on its own. The stealer runs python3 to read /proc/<pid>/mem of Runner.Worker. Harden-Runner raises a Runner-Worker memory-read detection for exactly this behavior, even when the network call is allowed. With a lockdown policy, Harden-Runner terminates the run as soon as it sees the memory read.
Harden-Runner blocking a bare-IP collection server on a non-standard port. This run is from the Megalodon campaign (216.126.225.129:8443); wfx's 194.150.166.244:8080 follows the same pattern
Harden-Runner flagging python3 reading the Runner.Worker process memory, here from the compromised actions-cool/issues-helper action. The wfx stealer does the same with python3 and sudo.

4. Malicious Files for GitHub: find the stealer after the revert

The attacker's cleanup removes the stealer from your branch and the run from your Actions tab, but not from git history. StepSecurity's Malicious Files for GitHub reads every file version in a repository's history, across branches, pull requests and tags. We have added three rules for this campaign:

  • wfx-ci-secret-stealer (critical): the stealer in a workflow or any shell script, in either delivery generation. It matches on the stealer's own markers, so it finds copies pasted into scripts like update.sh as well as new files.
  • wfx-npm-script-ci-helper-hook (critical): a package.json script that runs a hidden .ci-*.sh file.
  • wfx-probe-audit-notes (high): the docs/wfx-audit.md probe file. It marks an account whose token was used against the repository, even where no payload followed.

Each finding names the commit and author that introduced the file, so you can go straight to the compromised account. See the Malicious Files for GitHub documentation.

A Malicious Files rule is an ordered chain of filename and content checks; it fires only when every required check matches. Shown here for the Miasma campaign; the wfx rules work the same way.

5. GitHub Audit Log: trace deleted runs and compromised tokens

Wfx deletes workflow runs to cover its tracks, but the activity remains visible in StepSecurity's GitHub Audit Log.

  • Track deleted runs. Identify who deleted a workflow run, when, and from which repository using the Deleted runs view.
  • Spot suspicious activity. Investigate repeated deletions across repositories and workflows.
  • Trace compromised tokens. Review PAT activity to identify token owners, request origins, and actions performed.

Even after wfx removes the evidence from GitHub Actions, the Audit Log helps reconstruct the attack and identify compromised accounts.

What to Do Now

1. Find out whether you were hit

  • Search your organization's pull requests for docs: dependency audit notes and branches named wfx-docs or chore/<8 characters>.
  • Search commits since November 10 for ci: refresh build cache, ci: restore, ci: cleanup, chore: add ci helper and chore: add dependency audit workflow.
  • For each hit, open the Actions tab and look for a gap in run numbers right after the commit. The gap is the most reliable sign that the stealer ran. If the run survived, as in Nextcloud's case, read its log for the stealer's notes such as archive_bytes.
  • Note which account pushed the commits. That account's tokens are stolen.

2. If the stealer ran, rotate everything it could reach

  • Every repository, environment and organization secret available to that job. Do not stop at the secrets the workflow names; the memory scan collects the rest.
  • npm, PyPI and other publishing tokens. Check for package versions published after the commit.
  • Cloud credentials reachable through OIDC trust, and any long-lived keys stored as secrets. On self-hosted runners, also rotate the host's own credentials.

3. Remove the persistence, because rotation alone does not

  • AWS: look for role trust policies containing "Principal": {"AWS": "*"} and for IAM user access keys created since November 10. CloudTrail UpdateAssumeRolePolicy and CreateAccessKey events from a GitHub OIDC session are the signal.
  • Azure: list the federated identity credentials on your app registrations and delete any named wfx-ci-*.
  • GitHub: delete leftover wfx-docs and chore/* branches, close the probe pull requests, and check that no commit merged on a forged check.

4. Lock down the compromised account

Revoke the account's personal access tokens, OAuth app grants and SSH keys, and sign out its sessions. For bot accounts, review whether they need permission to bypass branch protection at all.

5. Developer machines

The package.json hook runs on any machine that installs dependencies. The window was short, usually a minute or two before the revert, but anyone who pulled and ran npm install in that window ran the stealer locally. Rotate that developer's credentials too.

‍

‍

Explore Related Posts

‍