Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

WFX Phantom: GitHub Actions Secret Stealer Campaign

A campaign using stolen GitHub tokens pushed the wfx stealer into thousands of repositories, harvesting CI secrets and cloud credentials since November 10.

TL;DR

  • A campaign using stolen GitHub tokens has pushed a shell stealer called wfx into repositories since 2026-11-10. Step Security published the analysis on 2026-11-12.
  • The attacker opens probe pull requests, hides the stealer in workflows or shell scripts, and deletes the workflow run after it executes. GitHub search showed more than 63,000 probe pull requests by 07:40 UTC on 2026-11-12.
  • The stealer collects repository secrets, runner memory and cloud credentials. It can persist in AWS and Azure. Some tokens came from the Fly.io GitHub App; no breach of Fly.io's own infrastructure is established.
  • Remove the commits and files, rotate every secret and cloud role the workflow could reach, and check for persistence. No CVE is assigned, so there is no CISA KEV entry as of 2026-11-12.

Am I affected?

You are exposed if any of these conditions hold:

  • A pull request titled docs: dependency audit notes, from a branch named wfx-docs, was opened in your repository on or after 2026-11-10.
  • A commit with one of the probe messages landed on a branch on or after 2026-11-10. The messages are ci: refresh build cache, ci: restore, ci: cleanup, chore: add ci helper and chore: add dependency audit workflow.
  • A workflow ran the stealer. The stealer can be an injected step or an appended shell script. It can also be a new .github/workflows/build-audit-<6 characters>.yml, or a hidden .ci-<6 characters>.sh hooked into postinstall, build or test.
  • The job could read repository or organisation secrets, a GITHUB_TOKEN that can request cloud sign-in tokens, or credentials on a self-hosted runner.
  • You installed one of the affected package versions.

A probe pull request means the token could write to the repository. It does not prove that the stealer ran. The attacker pushes the stealer only where the token permissions and the repository setup let it execute. The report estimates that this is thousands of repositories, not all 63,000 probes.

The strongest signal is a missing run number. If the Actions history jumps from one run number to the next right after one of the probe commits, the attacker likely ran the stealer and deleted the evidence. The stealer also reads the Runner.Worker process memory, so it can take secrets the workflow never passed to the job.

Package Ecosystem Affected version Clean version
digitalelvis/neuronai-studio Packagist 3.14.3 3.14.2 or earlier
github.com/onuragtas/docker-env Go v1.0.278 and v1.0.280 A version before 2026-11-11
github.com/vkuznet/gocurl Go v0.0.0-20261111095532-8d5aad0cb0da A commit before 2026-11-11 09:55 UTC

The two Go modules contain the stealer only in a workflow file, and Go builds never run workflow files. Block those versions anyway, because a scan or a mirror can still surface them. The Packagist release ships the .ci-*.sh script and a postinstall hook; composer install does not run it, but npm install inside the package directory does.

Check a local clone for the probe commits, the hidden scripts and the script markers:

# Probe commits
git log --all --since=2026-11-10 --oneline \
  --grep='ci: refresh build cache' \
  --grep='ci: restore' \
  --grep='ci: cleanup' \
  --grep='chore: add ci helper' \
  --grep='chore: add dependency audit workflow'

# Hidden scripts and workflows
git log --all --since=2026-11-10 --name-only --pretty=format: \
  | grep -E '\.ci-[a-z0-9]{6}\.sh|build-audit-[a-z0-9]{6}\.yml' | sort -u

# Stealer markers
grep -R -E 'WF_CALLBACK|WF_CTOKEN|WF_EXFIL_|WF_TG_BOTS|wfx-loot|WFX-BEGIN' . --exclude-dir=.git

Check the package versions:

composer show digitalelvis/neuronai-studio 2>/dev/null
go list -m all | grep -E 'onuragtas/docker-env|vkuznet/gocurl'

How do I fix it?

Remove the attacker's changes, then rotate every credential the workflow could reach. Removing the commit alone does not undo the theft, because the stealer may already have sent the secrets out and planted cloud persistence.

  1. Revert the malicious commit with git revert, or reset the branch to the last clean commit. Keep the attacker's commit and the workflow logs as evidence before you rewrite history.
  2. Delete the attacker's branches, close the probe pull requests and remove any collaborator or deploy key the attacker added.
  3. Rotate every secret the job could read: repository, organisation and environment secrets; personal access tokens; deploy keys; npm, PyPI, Docker and Vault credentials; SSH keys; and AI provider keys.
  4. Revoke the compromised GitHub account's sessions and tokens. If the token belonged to a GitHub App, remove the app's installation and reconnect it only after the account is clean.
  5. Remove the AWS and Azure persistence described in "What does the malicious code do?".
  6. Rebuild every package or release published between the malicious commit and the revert from a clean commit.
  7. Restrict who can push to default branches, require reviews, and set GITHUB_TOKEN permissions to read-only by default.

If you cannot rotate a credential immediately, restrict its scope or disable it. A long-lived AWS access key or an Azure federated credential that the attacker created survives a password change.

What does the malicious code do?

The stealer is a single shell script of about 1,500 lines with on and off switches for each feature. It runs in four stages: collect, escalate, persist and exfiltrate.

Collect. It reads every repository secret and variable through ${{ toJSON(secrets) }} and ${{ toJSON(vars) }}, plus the job's GITHUB_TOKEN and its ability to request cloud sign-in tokens. It dumps the memory of the Runner.Worker process, which holds every secret the job can use in decrypted form. It collects process environments and credential files for AWS, Azure, GCP, Docker, Kubernetes, npm, PyPI, SSH and Vault. It also takes API keys for AI providers and coding agents, and, on self-hosted runners, cloud instance metadata. It also snapshots the repository and recent artifacts.

Escalate. It uses the job's OpenID Connect token to open AWS, GCP and Azure sessions. It chains into further AWS roles named in the repository, impersonates GCP service accounts, and replays cached Azure CLI refresh tokens to reach Microsoft Graph and Key Vault.

Persist. It can rewrite an assumed AWS role's trust policy to "Principal": {"AWS": "*"} and call iam:CreateAccessKey for IAM users, which gives the attacker long-lived keys. It can add an Azure federated identity credential named wfx-ci-<n> to an Azure AD application. Workflows in the compromised repository can then sign in to Azure without a stored secret. A third feature can mark required checks as passed; the report says none of the 101 copies it examined had that switch on.

Exfiltrate. It packs the data into one gzipped archive. The report found three delivery configurations. The first sends an HTTP POST to 194.150.166[.]244:8080/exfil or 18.143.168[.]96:8080/exfil. The second adds a third server, 153.75.82[.]159:8080/exfil, and a Telegram backup. The third, first seen around 05:30 UTC on 2026-11-12, sends only to Telegram, through five bot tokens, into chat -1004340163187. Every copy sampled between 07:00 and 07:40 UTC used the Telegram-only configuration, so an IP blocklist built on the November 10 addresses no longer covers the newest copies.

The stealer hides in four places:

  • Two steps added to an existing workflow. The first hands the job's internal tokens to the next step, and the second runs the stealer under a name such as Restore cache x9xx.
  • The script appended to an existing shell script that a workflow already runs, such as update.sh in nextcloud/docker or tests/retry_env.sh in PancrePal-xiaoyibao/MinerU-xyb.
  • A new workflow, .github/workflows/build-audit-<6 characters>.yml, on a throwaway chore/<8 characters> branch.
  • A hidden .ci-<6 characters>.sh plus a package.json hook. The report found the hook on postinstall 11 times, build twice and test once.

After the workflow starts, the attacker pushes a revert (ci: restore, sometimes ci: cleanup) and deletes the workflow run with its logs. In drunomics/nuxtjs-drupal-ce, the stealer was committed at 09:17:23 UTC on 2026-11-11 and the revert followed 12 seconds later; the Actions history goes from run #1963 straight to #1965, and run #1964 is gone.

When the deletion fails, the run log shows what the stealer did. In nextcloud/docker, the surviving run printed:

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

Those lines show a self-hosted runner, a memory scan that found three files, and an 8,971-byte archive. They also show that the GCP, Azure and OIDC paths did not yield a token in that run. The archive upload succeeded, because the script prints a retry message on failure and the log has none. That run collected data from the machine itself, not only from GitHub's hosted runner.

How is it exploited?

  1. The attacker holds a stolen token with write access to the repository. The token can belong to a developer, a bot account or a GitHub App.
  2. The attacker opens the probe pull request from wfx-docs. The pull request adds one static file, docs/wfx-audit.md, and tells the attacker that the token can write.
  3. The attacker pushes the stealer in one of the four hiding places. The commit looks like maintenance work, and the author is often a bot account that can bypass branch protection.
  4. A workflow runs on the commit. The stealer collects secrets, requests cloud tokens and sends the archive out. The attacker reverts the commit and deletes the run, usually within a minute.
  5. The attacker uses the stolen cloud credentials or the planted persistence after the repository is clean. The report says the same actor may return through the persistence even after the token is revoked.

The report attributes 173 probe pull requests from 19 accounts to tokens issued to the Fly.io GitHub App (fly-io, app ID 34557). The app has write access to contents, actions and pull requests, which is enough for every step.

The report says it has not established how those tokens were obtained and has no evidence of a breach of Fly.io's own infrastructure. Most of the campaign uses other credentials, such as personal access tokens.

How does the attack flow?

A stolen token opens a probe pull request, and the attacker pushes a stealer that runs inside a normal workflow. The run sends secrets and cloud tokens out before the attacker reverts the commit and deletes the run.

flowchart LR
    A["Stolen GitHub token"] --> B["Probe PR<br/>docs: dependency audit notes"]
    B --> C["Push stealer<br/>workflow, shell script or npm hook"]
    C --> D["GitHub Actions run<br/>reads secrets and Runner.Worker memory"]
    D --> E["Cloud OIDC<br/>AWS, GCP, Azure"]
    D --> F["Exfil archive<br/>HTTP server or Telegram"]
    E --> G["Persistence<br/>AWS trust policy, Azure credential"]
    F --> H["Attacker reverts commit<br/>and deletes the run"]

What can an attacker do?

An attacker with a write token can read every secret the workflow can reach, including secrets the workflow never references, because the stealer dumps the runner process memory. The attacker can use the job's cloud sign-in token to assume roles in AWS, GCP and Azure, and can plant persistence that survives token rotation.

The impact on a repository depends on what the workflow can do. A repository with only read access to its own code loses less than one with a cloud deploy role or an npm publish token. A self-hosted runner that holds production credentials is the worst case. The report's surviving nextcloud/docker run shows the stealer reading the runner machine itself, which is the worst case for a self-hosted runner.

A probe pull request alone does not give the attacker any of this. The stealer must run, and the token must have the permissions the workflow uses. The report does not publish a victim count or a list of confirmed data thefts.

What should I do if I installed it?

If a workflow ran the stealer, treat the repository, the runner and every credential the job could reach as compromised. Removing the package or the commit is only the first step.

  1. Isolate the self-hosted runner and preserve its disk, process list and logs. Do not reuse it until you have rebuilt it from a clean image.
  2. Preserve the workflow run logs and the attacker's commits. The missing run number and the commit history are evidence for the incident review.
  3. Rotate every secret and token the job could read, from a clean machine. Start with cloud roles and long-lived keys, because the stealer targets those first.
  4. Remove the AWS role trust policy that allows "AWS": "*", delete IAM access keys you did not create, and remove Azure federated credentials named wfx-ci-<n>.
  5. Check for new repositories, collaborators, deploy keys, webhooks and workflow files created with the stolen token.
  6. Rebuild any package published while the stealer was present, and publish it again from a clean commit with a new version.

If you only installed digitalelvis/neuronai-studio 3.14.3 and the postinstall hook did not run, remove the package and pin 3.14.2 or earlier. If the hook ran, follow the same incident steps.

How do I check if I was compromised?

Start with the GitHub searches and the local checks in "Am I affected?". The probe pull request, the commit messages and the hidden scripts are public evidence.

# On GitHub: probe pull requests in your org
https://github.com/search?q=%22docs%3A+dependency+audit+notes%22+is%3Apr&type=pullrequests

Look for a missing run number in the Actions tab right after one of the probe commits. A run that disappeared is the clearest sign that the stealer executed and the attacker cleaned up.

Search code for the collection servers and the script markers:

grep -R -E '194\.150\.166\.244|18\.143\.168\.96|153\.75\.82\.159|WF_CALLBACK|WF_CTOKEN|WF_TG_BOTS' . --exclude-dir=.git

Check the cloud persistence:

# AWS: a role that trusts every account is the planted back door
aws iam get-role --role-name <role-name> --query 'Role.AssumeRolePolicyDocument'
aws iam list-access-keys --user-name <user-name>

# Azure: federated credentials named wfx-ci-<n>
az ad app federated-credential list --id <application-id>

The report lists these indicators:

  • Collection servers: 194.150.166[.]244:8080/exfil, 18.143.168[.]96:8080/exfil and 153.75.82[.]159:8080/exfil.
  • Callback tokens: b42d70a7aab9140ca19e8249 and 15f44heehnax8jdf41yx, sent as t=.
  • Telegram: api.telegram.org sendDocument, chat -1004340163187, bot IDs 8708988906, 9196217635, 9180059428, 8902935164 and 8876515636.
  • Probe pull request: title docs: dependency audit notes, branch wfx-docs, file docs/wfx-audit.md, git blob ee6e5addfe591923fc385553cb66cf0d25634a6b.
  • Branches and files: chore/[a-z0-9]{8}, ci-check-[0-9]{4}, .ci-[a-z0-9]{6}.sh and .github/workflows/build-audit-[a-z0-9]{6}.yml.
  • Script markers: steps named Pass runtime and Restore cache <4 characters> or Run audit; variables WF_CALLBACK, WF_CTOKEN, WF_EXFIL_* and WF_TG_BOTS; staging folder .wfx-$$; log notes memscan_files, archive_bytes and deliver_retry.

The report does not publish a victim count, and a deleted run may leave only the missing run number as evidence.

Timeline

  • 2026-11-10: The campaign starts. The first copies send data to 194.150.166[.]244.
  • 2026-11-11 09:17: The stealer is committed to drunomics/nuxtjs-drupal-ce; the revert follows 12 seconds later and run #1964 is deleted.
  • 2026-11-11 10:02 to 10:36: A burst of probe pull requests uses tokens issued to the Fly.io GitHub App.
  • 2026-11-12 01:10: A Telegram backup channel appears in the samples.
  • 2026-11-12 05:30: New copies switch to Telegram-only delivery.
  • 2026-11-12 07:40: GitHub search returns more than 63,000 probe pull requests.
  • 2026-11-12 10:47: Step Security publishes its analysis.

The campaign's first 35 hours, from the first HTTP exfiltration to the Telegram-only switch and the 63,000-probe milestone.
The campaign timeline from 2026-11-10 to the 2026-11-12 report. Source: Step Security, checked November 12, 2026

FAQ

Does a probe pull request mean my repository was compromised?

No. The probe only shows that the stolen token could write to the repository. Look for the payload commit, the hidden script and a missing workflow run. If the stealer ran, rotate the secrets.

Did GitHub or Fly.io suffer a breach?

The report says it has no evidence of a breach of Fly.io's own infrastructure. The Fly.io GitHub App issued tokens to users who connected their accounts, and the attacker used some of those tokens. How the tokens were obtained is not established. GitHub's own platform is not implicated: the campaign abuses valid GitHub tokens and normal workflow features, which is also why no CVE is assigned.

Is there a CVE for this campaign?

No. The campaign abuses valid GitHub tokens and normal workflow features, so no CVE is assigned. The affected package versions are listed by their registries, not by a CVE.

Is the campaign in CISA KEV?

No. No CVE is assigned, and CISA KEV lists CVEs. There is no KEV entry as of 2026-11-12.

Which packages carry the stealer?

digitalelvis/neuronai-studio 3.14.3 on Packagist, github.com/onuragtas/docker-env v1.0.278 and v1.0.280, and the github.com/vkuznet/gocurl pseudo-version v0.0.0-20261111095532-8d5aad0cb0da. The two Go modules carry it only in a workflow file.

The attacker deleted the run. Can I still tell whether it executed?

The missing run number and the probe commit are the evidence. A run that disappears between two numbered runs right after the commit is the pattern. Rotate the credentials even if you cannot recover the log.

References

  • Step Security, "WFX Phantom: The GitHub Actions Secret Stealer That Deletes Its Tracks", published 2026-11-12: https://www.stepsecurity.io/blog/wfx-phantom-the-github-actions-secret-stealer (checked November 12, 2026)
  • Packagist, digitalelvis/neuronai-studio versions 3.14.2 to 3.14.4: https://packagist.org/packages/digitalelvis/neuronai-studio (checked November 12, 2026)
  • Go module proxy, github.com/onuragtas/docker-env v1.0.278 and v1.0.280: https://pkg.go.dev/github.com/onuragtas/docker-env (checked November 12, 2026)
  • Go module proxy, github.com/vkuznet/gocurl pseudo-version: https://pkg.go.dev/github.com/vkuznet/gocurl (checked November 12, 2026)
  • GitHub, probe pull request cited by the report: https://github.com/EbukaLovesTech/2gether/pull/1 (checked November 12, 2026)