Not that long ago, supply chain security mostly meant keeping your packages updated so you didn't inherit new vulnerabilities. Then, for the last year, open source malware has mostly meant worms. Shai-Hulud and its descendants taught us that a compromised npm maintainer can turn into hundreds of compromised packages before anyone has finished their coffee.
WFX Phantom is something slightly different but also strangely familiar. It isn't trying to spread through the supply chain, not this time, but it did learn something from Shai-Hulud. It's going straight for the place where all your secrets live, your CI pipeline. And it didn't start from scratch. It ran on GitHub credentials that had already been stolen and were still valid, but how they first obtained them remains unknown.
It's a stark reminder that rotating credentials after a compromise is one of the cheapest defenses there is.
Nextcloud runs update.sh
In nextcloud/docker, a workflow kicks off. It runs update.sh, like it always does.
Three seconds earlier, someone appended about 1,500 lines to that script.
The job dumps the memory of the runner process. It's a self-hosted runner, so it collects data from the host too. Packing everything into an 8,971 byte archive and uploading it on the first attempt.
The job finishes green. But 27 minutes later a Nextcloud developer reverts the commit, unfortunately the data is long gone.
This is one of the lucky ones. The attacker's plan to clean up their mess failed, so the run log survived and we can see what happened. In most of the 1,800-plus other repositories where the payload ran, the run was deleted, and all that's left is a missing run number.
To understand how a green build became a breach, we need to go back one month earlier to another campaign targeting GitHub, GhostAction.
Rewind: October 2026
The GhostAction campaign hits GitHub accounts with secret-scanning workflows and a developer-machine stealer that takes git credentials, SSH keys, and cloud and package registry tokens.
When investigators looked back on the WFX Phantom victims, they found something interesting: Accounts compromised here turned out to be far more likely to be hit again in this new campaign. The two campaigns look like different actors and do have different TTPs, but stolen tokens often get sold, shared and reused. And in more than 100 repositories, GhostAction's malicious workflow files were still sitting there a month later, because nobody had cleaned them up. WFX Phantom would later be injected straight into them.
Researchers haven't established exactly where every WFX Phantom token came from. But they didn't appear out of nowhere: each one was stolen at some point, and each one still worked.
This is the first link in the chain: tokens that were stolen, and never rotated.
Why CI?
A stolen GitHub token, even one with write access, cannot read your repository secrets. The API will never hand you a secret's value.
But a workflow can use those secrets. So if you can push code that runs in CI, that code executes with every secret, the job's GITHUB_TOKEN, the cloud identity configured through OIDC, and the runner's own memory sitting right there. So in practice, write access to your repo means access to your AWS keys.
CI has always been a good place to steal secrets from. Historically, though, attackers have gone for developer machines instead, because pulling credentials out of a runner, exchanging OIDC tokens for cloud sessions across three providers, and reading decrypted secrets out of process memory takes a fair bit of know-how, and that friction has kept CI lower down attackers' lists. In contrast, WFX Phantom does all of that in one shell script.
The script is plain, heavily commented, unobfuscated, neatly structured, and covers an almost suspiciously broad range of clouds and tooling. But before you can start your attack, you need to know what you have access to.
10 November, 12:45 UTC: the probe
The first probe pull requests appear. Each one is titled docs: dependency audit notes, comes from a wfx-docs branch, and adds a single two-line file, docs/wfx-audit.md, byte-identical in every PR researchers checked.
The PR does nothing on its own. It's a test of whether the stolen token can write to the repository. If it can, the attacker comes back with the real payload later.
Researchers counted 4,837 compromised accounts opening probes into 61,764 repositories. Over 10,000 of those landed in repos the account didn't actually own, but instead reached through collaborator or organisation access.
10 to 11 November: the payload
Where the probe succeeds, the attacker pushes the stealer. It has four hiding places:
ci: refresh build cache: two steps added to an existing workflow, aPass runtimestep and aRestore cache <4ch>step holding the whole script, plusstatuses: writeadded to permissions. By far the most common: over 21,000 commits carry this message (a count that also includes the next variant).ci: refresh buildcache again, but appended to a shell script CI already runs, likeupdate.sh. This is what happened to Nextcloud.chore: add dependency audit workflow: a brand newbuild-audit-<6ch>.ymlon a throwawaychore/<8ch>branch.chore: add ci helper: a hidden.ci-<6ch>.shfile plus apackage.jsonhook, usually our old friendpostinstall.
Getting the stealer to run is only half the job for these attackers, the next is getting rid of the evidence.
Clean Up on drunomics/nuxtjs-drupal-ce
09:17:23 UTC, 11 November. The stealer lands on the default branch, pushed by the project's bot account, drubot. The workflow starts.
First, it opens the gate. The script posts forged success statuses under the names of the repository's required checks. Branch protection matches checks by name, not by who posted them (unless you've pinned each required check to a specific app), so the merge gate is open while the stealer is still running.
Then it collects. Every repository secret and variable, the job's tokens, credential files for every cloud and package registry you've heard of, AI provider and coding agent keys, shell histories. It reads the memory of the Runner.Worker process, where decrypted secrets live, including ones the workflow never even referenced.
Then it escalates. It exchanges the job's OIDC token for AWS, GCP and Azure sessions and chains into further roles.
Then it digs in. It rewrites an AWS role trust policy to "Principal": {"AWS": "*"} and tries to mint long-lived IAM access keys. It adds a federated identity credential named wfx-ci-<n> to Azure AD applications, so workflows in the compromised repo can sign into Azure with no stored secret at all. Rotate every key you like; that door stays open.
Then it ships everything out as one gzipped archive.
09:17:35 UTC. Twelve seconds after the stealer landed, a ci: restore commit reverts it.
Shortly after. Run #1964, the one that executed the stealer, is deleted along with its logs. The Actions history now jumps from #1963 straight to #1965.
The attacker's commit and its revert are still in the git history, but nothing there says the stealer ran. That missing run number is the only sign it did.
Meanwhile, in WebdriverIO
The two chore: add ci helper commits are pushed as the project's bot, wdio-bot, which was allowed to bypass branch protection. A contributor spots them within 20 minutes and opens a revert. Credit where it's due, that's quick. But in drunomics, the attacker had already cleaned up after 12 seconds.
Aftermath: 12 November
The attack is still ongoing. New probes appear every few minutes.
At around 05:30 UTC, new copies of the stealer stop using an IP address at all. Stolen data now goes to a Telegram chat via sendDocument, with the HTTP callback pointed at 127.0.0.1. A comment in the script helpfully notes there's "no callback IP anywhere in the payload". These newer versions also carry Indonesian-language comments, the result of what looks like a hasty edit.
By the end of the day, GitHub search returns around 62,000 probe PRs, around 90% of them still open. At least 1,800 repositories ran the payload, and that's only the ones where evidence survived. The payload has also leaked into at least one Packagist package and one Go module through automated publishing triggers.
This attack was a result of a series of failures
Tokens that were never rotated. Every token used in this wave was stolen at some point and still worked. Accounts hit by GhostAction in October were significantly more likely to be hit again. A rotated token is a worthless token.
Config nobody reads. GitHub Actions sits somewhere between code and infrastructure, so it gets reviewed like neither. It's config that runs with the keys to the kingdom. We tend to think of workflow risk as misconfiguration. WFX Phantom shows us that even correctly configured workflows are a target, if someone else can write to them. GhostAction's workflows were still in repos a month later. Cleanup is hard when your attitude to GitHub Actions is "if it's not broken, don't touch it".
Bots with too much power. In both drunomics and WebdriverIO, the token belonged to a bot. Automation accounts with broad write access, and especially those allowed to bypass branch protection, are worth at least as much to an attacker as a maintainer and branch protection let a forged status stand in for a real one.
Builds that can talk to anything. Neither a bare IP nor Telegram belongs in a normal build, but nothing stopped either.
Another day, another supply chain attack
What can you do? Well start with the boring question:
Do we have GitHub Actions, and how are they set up?
Then, for activity since 10 November:
Look for the probe. Search your org for PRs titled
docs: dependency audit notesor branches namedwfx-docs. If you find one, the account that opened it is compromised.Look for missing run numbers. A gap in the Actions history right after a ci: refresh build cache,
chore: add ci helperorchore: add dependency audit workflowcommit means the stealer probably ran. Treat every secret and cloud role that workflow could reach as stolen.Check for persistence, not just secrets. Look for Azure federated identity credentials named
wfx-ci-*, AWS roles with a wildcard principal, and AWS sessions namedwfx-*.If you were hit by GhostAction, remove the leftover workflows.
Audit your bots. What can they write to, and can they bypass branch protection?
Allowlist egress from CI. No workflow needs to connect to telegram, but you’ll always be playing catch up as the attack builds.