Gheist GitHub Token Attack: Hunt ci-sync Secret Dumps


What this means for your clients: a stolen GitHub token with write access can read nearly every secret a repository can use, with no malware and no attacker server involved.
A branch named ci-sync- plus six random characters appears in a private repository. Its one unsigned commit, ci: sync config, adds a single file, .github/workflows/sync-check.yml, under the name of whoever owned the stolen token. The push runs the workflow, the workflow prints the repository's secrets into its own run log, a script downloads the log through the GitHub API, and the run is deleted seconds later.
That is the Gheist GitHub token attack, which Wiz has tracked since September 15, 2026 and named for the gheist user agent on its API calls. Wiz says it recovered more than 13,500 planted branches from more than 850 compromised accounts, and that as of October 24 the campaign was still evolving. Wiz gives no count of organizations whose secrets were exposed, and neither do we.
Wiz says Gheist is distinct from GhostAction, which uses different workflow files and sends secrets to 193[.]32[.]204[.]199. Our GhostAction hunt and LiteLLM post cover those campaigns.
Why masking and an empty permissions block do not help
GitHub masks a secret only when it appears verbatim, and its own guidance says redaction is not guaranteed once a value is transformed. The workflow base32 encodes the environment and every secret the job can read, so the verbatim masking misses it and the values print in a form anyone can decode. It also sets permissions: {}, which only limits the job's GITHUB_TOKEN, a token the attacker never needed. GitHub documents that anyone with write access can read every secret configured in a repository.
One variant lists the repository's deployment environments in a job matrix, so environment secrets print too, often production credentials per Wiz. There is no attacker domain to block.
The approval hold covered the wrong repositories
Since July 28, 2026, GitHub has held potentially malicious runs in public repositories until a collaborator with write access approves them, and every public Gheist run Wiz checked sat at action_required. GitHub's changelog says the approval hold "currently applies to public repositories on github.com only" and that GitHub Enterprise Server does not add it. Private repositories, where client code lives, are where Wiz saw runs complete, and Enterprise Server does not have this protection.
In enterprise organizations, Wiz saw the tool cycle tokens and clone private repositories; hours later, cloud credentials from the same organization enumerated AWS Secrets Manager. Wiz does not say where those credentials came from or who used them, so treat the link as a timing correlation, not proof.
An October 19 job called sync-host goes after credentials on self-hosted runners, though Wiz describes it as buggy.
The stolen tokens Wiz could trace came from infostealer logs and earlier leaks such as the March Trivy compromise. Some victims also appear to be infected with PolinRider, malware that researchers link to North Korea, per The Hacker News.
Respond, then make the next stolen token less useful
If a ci-sync branch ran, rotate every repository, organization and environment secret that repository could read; deleting the run or branch does not undo it, because the attacker has most likely already downloaded the log. Revoke the tokens that pushed the commits and check their owners' devices for an infostealer. Treat any self-hosted runner that executed a ci-sync branch as compromised, along with its instance role. Never approve a held sync-check run.
Hardening makes write access worth less. Put required reviewers on production environments, since GitHub says a job cannot read those environment secrets until a reviewer approves. On Free, Pro and Team plans, required reviewers on environments only work for public repositories, which matters for small clients. Scope OIDC trust to protected branches or environments.
Swap personal access tokens for GitHub Apps or short-lived fine-grained tokens, and run self-hosted runners as ephemeral hosts without the Docker socket or open metadata.
Hunts to run (test first)
These hunts find evidence of the campaign, from planted commits to completed runs, and none of it proves a secret was used. The table under the hunts, "Interpret before you escalate", says what is benign and what is not. Field names are unverified assumptions. GitHub keeps the org audit log 180 days and GitHub Actions logs 90 days by default.
Test query. Confirm CloudTrail and GitHub audit or workflow data reach the index. An empty result means no data came back, which points to ingestion, the time range, access or source names that differ from ours, so check it against an event you know arrived.
Splunk
index=* (sourcetype=aws:cloudtrail OR sourcetype=github:*) earliest=-24h
| stats count by index, sourcetype
| sort - countElastic
FROM logs-aws.cloudtrail-*, logs-github.*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY data_stream.dataset
| SORT count DESCCrowdStrike
// Time picker: last 24 hours. Assumes CloudTrail and GitHub audit ingested into Falcon Next-Gen SIEM
#Vendor=/^(aws|github)$/i
| groupBy([#Vendor, #repo], function=count(as=count))
| sort(count, order=desc)Microsoft KQL
union isfuzzy=true withsource=SourceTable AWSCloudTrail, GitHubAudit_CL
| where TimeGenerated > ago(24h)
| summarize count() by SourceTable
| order by count_ descEvery source name here is an assumption to check against your own ingestion: the aws:cloudtrail and github:* sourcetypes, the logs-aws.cloudtrail-* and logs-github.* data streams, the aws and github vendor tags in Falcon, and the AWSCloudTrail and GitHubAudit_CL tables in Sentinel. The GitHub ones in particular are ours, not vendor defaults. Swap in whatever your connector or custom stream actually created.
Hunt 1: AWS CloudTrail. This needs only CloudTrail management events. It finds AssumeRoleWithWebIdentity from token.actions.githubusercontent.com with ci-sync- in the subject, then counts ListSecrets and GetSecretValue in that account over the next 12 hours.
All four enforce the window, with one main difference: Splunk, CrowdStrike and KQL anchor on the first matching role assumption in each account, while the Elastic sequence can start from any of them, so it may catch a later burst the others miss. The Elastic version also matches both strings anywhere in the serialized response rather than in the provider and subject fields, so treat it as a slightly broader candidate search. The 12 hours and 20 calls are our thresholds, not Wiz's. GitHub's OIDC docs show that, by default, the branch is in the subject only when the job uses no environment, so environment-bound jobs slip past unless you customized the subject claim.
Splunk
index=aws sourcetype=aws:cloudtrail
((eventName=AssumeRoleWithWebIdentity "responseElements.provider"="*token.actions.githubusercontent.com*" "responseElements.subjectFromWebIdentityToken"="*ci-sync-*")
OR (eventSource="secretsmanager.amazonaws.com" eventName IN ("ListSecrets", "GetSecretValue")))
| eval oidc_t=if(eventName="AssumeRoleWithWebIdentity", _time, null())
| eventstats min(oidc_t) as first_oidc values(responseElements.subjectFromWebIdentityToken) as subject by recipientAccountId
| where isnotnull(first_oidc) AND eventSource="secretsmanager.amazonaws.com" AND _time>first_oidc AND _time<=first_oidc+43200
| stats count as sm_calls min(_time) as first_sm values(subject) as subject by recipientAccountId, first_oidc
| where sm_calls>=20Elastic
// EQL against logs-aws.cloudtrail-*
sequence by cloud.account.id with maxspan=12h
[any where event.action == "AssumeRoleWithWebIdentity"
and aws.cloudtrail.response_elements like~ "*token.actions.githubusercontent.com*"
and aws.cloudtrail.response_elements like~ "*ci-sync-*"]
[any where event.provider == "secretsmanager.amazonaws.com"
and event.action in ("ListSecrets", "GetSecretValue")] with runs=20CrowdStrike
// Assumes CloudTrail ingested into Falcon Next-Gen SIEM with raw JSON field names
#Vendor=aws eventSource="secretsmanager.amazonaws.com" eventName=/^(ListSecrets|GetSecretValue)$/
| join(query={
#Vendor=aws eventName="AssumeRoleWithWebIdentity"
| responseElements.provider=/token\.actions\.githubusercontent\.com/
| responseElements.subjectFromWebIdentityToken=/ci-sync-/
| groupBy([recipientAccountId], function=min(@timestamp, as=first_oidc))
}, field=recipientAccountId, include=[first_oidc])
| test(@timestamp > first_oidc)
| test(@timestamp <= first_oidc + 43200000)
| groupBy([recipientAccountId, first_oidc], function=count(as=sm_calls))
| test(sm_calls >= 20)Microsoft KQL
let oidc = AWSCloudTrail
| where EventName == "AssumeRoleWithWebIdentity"
| extend r = parse_json(ResponseElements)
| where tostring(r.provider) contains "token.actions.githubusercontent.com"
and tostring(r.subjectFromWebIdentityToken) contains "ci-sync-"
| summarize first_oidc = min(TimeGenerated), subject = make_set(tostring(r.subjectFromWebIdentityToken), 10) by RecipientAccountId;
AWSCloudTrail
| where EventSource == "secretsmanager.amazonaws.com" and EventName in ("ListSecrets", "GetSecretValue")
| join kind=inner oidc on RecipientAccountId
| where TimeGenerated > first_oidc and TimeGenerated <= first_oidc + 12h
| summarize sm_calls = count(), first_sm = min(TimeGenerated) by RecipientAccountId, first_oidc, tostring(subject)
| where sm_calls >= 20How your pipeline flattens responseElements decides whether subjectFromWebIdentityToken exists as a field or must be matched in the raw string, as Elastic does here. The Splunk index=aws, the Falcon #Vendor=aws tag and the flattened responseElements.* names are our assumptions. Check the Sentinel AWSCloudTrail columns and the Elastic aws.cloudtrail.*, cloud.account.id and event.* fields against your mapping as well. The Elastic EQL expects aws.cloudtrail.response_elements to be a keyword field holding the whole serialized response, which is why its two like~ matches find the substrings anywhere in it. If that field is text in your mapping, point the query at a keyword copy. On a busy account an application can make 20 calls on its own, so check the subject before escalating.
Hunt 2: GitHub audit log. This needs audit log streaming or export. It groups events by repository and run, keeps groups touching a ^ci-sync-[0-9a-z]{6}$ branch or the gheist user agent, and in each query flags runs deleted within 60 seconds of completing.
Splunk
index=github sourcetype=github:audit earliest=-180d
| eval branch=coalesce(head_branch, replace(ref, "^refs/heads/", ""))
| eval workflow_run_id=coalesce(tostring(workflow_run_id), "none")
| eval done_t=if(action="workflows.completed_workflow_run", _time, null()), del_t=if(action="workflows.delete_workflow_run", _time, null())
| eval hit=if(match(branch, "^ci-sync-[0-9a-z]{6}$") OR like(lower(user_agent), "%gheist%"), 1, 0)
| stats min(done_t) as done_t min(del_t) as del_t max(hit) as hits values(action) as actions values(actor) as actors values(branch) as branches by repo, workflow_run_id
| where hits=1
| eval fast_delete=if(isnotnull(del_t) AND workflow_run_id!="none" AND isnotnull(done_t) AND del_t>=done_t AND del_t-done_t<=60, "yes", "no")Elastic
FROM logs-github.audit-*
| WHERE @timestamp > NOW() - 180 days
| EVAL branch = COALESCE(github.head_branch, REPLACE(github.ref, "^refs/heads/", ""))
| EVAL run_id = COALESCE(TO_STRING(github.workflow_run_id), "none")
| EVAL done_t = CASE(event.action == "workflows.completed_workflow_run", @timestamp, NULL)
| EVAL del_t = CASE(event.action == "workflows.delete_workflow_run", @timestamp, NULL)
| EVAL hit = CASE(branch RLIKE "ci-sync-[0-9a-z]{6}" OR TO_LOWER(user_agent.original) LIKE "*gheist*", 1, 0)
| STATS done_t = MIN(done_t), del_t = MIN(del_t), hits = MAX(hit), actions = VALUES(event.action), actors = VALUES(user.name), branches = VALUES(branch) BY github.repo, run_id
| WHERE hits == 1
| EVAL fast_delete = CASE(del_t IS NOT NULL AND run_id != "none" AND done_t IS NOT NULL AND del_t >= done_t AND del_t <= done_t + 60 seconds, "yes", "no")CrowdStrike
// Time picker: last 180 days. Assumes GitHub audit log streamed into Falcon Next-Gen SIEM
#Vendor=github
| coalesce([head_branch, ref], as=branch)
| replace(regex="^refs/heads/", with="", field=branch, as=branch)
| default(field=workflow_run_id, value="none")
| case { action="workflows.completed_workflow_run" | done_t := @timestamp; action="workflows.delete_workflow_run" | del_t := @timestamp; * }
| case { branch=/^ci-sync-[0-9a-z]{6}$/ | hit := 1; user_agent=/gheist/i | hit := 1; * | hit := 0 }
| groupBy([repo, workflow_run_id], function=[min(done_t, as=done_t), min(del_t, as=del_t), max(hit, as=hits), collect([action, actor, branch])])
| test(hits == 1)
| gap := del_t - done_t
| fast_delete := if(workflow_run_id != "none" AND gap >= 0 AND gap <= 60000, then="yes", else="no")Microsoft KQL
GitHubAudit_CL
| where TimeGenerated > ago(180d)
| extend branch = coalesce(head_branch, replace_regex(ref, @"^refs/heads/", ""))
| extend run_id = coalesce(tostring(workflow_run_id), "none")
| extend done_t = iff(action == "workflows.completed_workflow_run", TimeGenerated, datetime(null))
| extend del_t = iff(action == "workflows.delete_workflow_run", TimeGenerated, datetime(null))
| extend hit = iff(branch matches regex "^ci-sync-[0-9a-z]{6}$" or user_agent contains "gheist", 1, 0)
| summarize done_t = min(done_t), del_t = min(del_t), hits = max(hit), actions = make_set(action), actors = make_set(actor), branches = make_set(branch) by repo, run_id
| where hits == 1
| extend fast_delete = iff(isnotnull(del_t) and run_id != "none" and isnotnull(done_t) and del_t >= done_t and (del_t - done_t) <= 60s, "yes", "no")Action names break this first. The Splunk index=github, the github:audit sourcetype, logs-github.audit-*, Falcon's #Vendor=github tag and GitHubAudit_CL are our names, and so are the branch, ref, run ID, actor, repository and user agent fields in all four queries. workflows.completed_workflow_run and workflows.delete_workflow_run are our best reading of GitHub's audit schema, so check what your stream calls run completion and deletion, and whether deletion events carry the branch. Events with no run ID, such as a gheist push, fall into one none group per repository, which still surfaces the user agent, and the queries never mark that group as a fast delete. Cleanup automation mimics fast deletes.
Hunt 3: repository content. For most SMBs this is a GitHub API or code scanning job, not a SIEM search. Where commit events are ingested, these find an unverified ci: sync config commit whose added files are only sync-check.yml, or a match for Wiz's four blob SHAs, which are in all four queries. Environment matrix variants have no fixed hash.
f3da2b8b357dcda02fd455d314be2a95007516a6 (plain)
d09535857a741bbb976bdc084a9a021713133291 (timeout)
1dc5af9cce950aad1eb1546ecce8565b117540c4 (separator)
c665b778b2d822453293003e7ca0bbb2cb08e39d (self-hosted job)
Splunk
index=git sourcetype=git:commit
| eval only_file=if(mvcount(files_added)=1 AND files_added=".github/workflows/sync-check.yml", 1, 0)
| where (message="ci: sync config" AND only_file=1 AND verified="false")
OR blob_sha IN ("f3da2b8b357dcda02fd455d314be2a95007516a6", "d09535857a741bbb976bdc084a9a021713133291", "1dc5af9cce950aad1eb1546ecce8565b117540c4", "c665b778b2d822453293003e7ca0bbb2cb08e39d")
| stats values(branch) as branches values(author) as authors count by repoElastic
FROM logs-git.commit-*
| WHERE (git.message == "ci: sync config" AND MV_COUNT(git.files_added) == 1
AND git.files_added == ".github/workflows/sync-check.yml" AND git.verified == false)
OR git.blob_sha IN ("f3da2b8b357dcda02fd455d314be2a95007516a6", "d09535857a741bbb976bdc084a9a021713133291", "1dc5af9cce950aad1eb1546ecce8565b117540c4", "c665b778b2d822453293003e7ca0bbb2cb08e39d")
| STATS branches = VALUES(git.branch), authors = VALUES(git.author), count = COUNT(*) BY git.repoCrowdStrike
// Assumes commit or code-scan events ingested into Falcon Next-Gen SIEM
#type=git_commit
| case {
message="ci: sync config" verified=false files_added=".github/workflows/sync-check.yml" files_added_count=1 | hit := 1;
blob_sha=/^(f3da2b8b357dcda02fd455d314be2a95007516a6|d09535857a741bbb976bdc084a9a021713133291|1dc5af9cce950aad1eb1546ecce8565b117540c4|c665b778b2d822453293003e7ca0bbb2cb08e39d)$/ | hit := 1;
* | hit := 0 }
| test(hit == 1)
| groupBy([repo], function=[collect([branch, author]), count(as=count)])Microsoft KQL
GitCommits_CL
| extend files = todynamic(files_added)
| where (message == "ci: sync config" and array_length(files) == 1
and tostring(files[0]) == ".github/workflows/sync-check.yml" and verified == false)
or blob_sha in ("f3da2b8b357dcda02fd455d314be2a95007516a6", "d09535857a741bbb976bdc084a9a021713133291", "1dc5af9cce950aad1eb1546ecce8565b117540c4", "c665b778b2d822453293003e7ca0bbb2cb08e39d")
| summarize branches = make_set(branch), authors = make_set(author), count() by repoThe collector defines this schema, so index=git, git:commit, logs-git.commit-*, #type=git_commit and GitCommits_CL are placeholders for whatever yours writes, and added files are the field most likely to differ: array, count or joined string. A signed commit that failed verification also shows as unverified, and the queries check only added files, so read the full commit before acting.
Hunt 4: self-hosted runners. This needs EDR on runner hosts and ingested job logs. It looks for .gdmp_pat, .gdmp_patg and .gdmp_sweep, gdmp-pivot or a sync-host job in job logs, and it also flags new runner registrations, a check we added that is not a Wiz IOC. None of the four enforces a window against ci-sync activity, so compare times with Hunt 2 by hand.
Splunk
(index=edr file_name IN (".gdmp_pat", ".gdmp_patg", ".gdmp_sweep"))
OR (index=github sourcetype=github:joblog ("gdmp-pivot" OR job_name="sync-host"))
OR (index=github sourcetype=github:audit action IN ("repo.register_self_hosted_runner", "org.register_self_hosted_runner"))
| eval signal=case(isnotnull(file_name), "gdmp file", like(action, "%register_self_hosted_runner"), "runner registered (our check)", true(), "job log")
| fillnull value="n/a" host repo
| stats min(_time) as first_seen count by signal, host, repoElastic
FROM logs-endpoint.events.file-*, logs-github.*
| WHERE file.name IN (".gdmp_pat", ".gdmp_patg", ".gdmp_sweep")
OR message LIKE "*gdmp-pivot*" OR github.job_name == "sync-host"
OR event.action IN ("repo.register_self_hosted_runner", "org.register_self_hosted_runner")
| EVAL signal = CASE(file.name IS NOT NULL, "gdmp file", event.action LIKE "*register_self_hosted_runner", "runner registered (our check)", "job log")
| STATS first_seen = MIN(@timestamp), count = COUNT(*) BY signal, host.name, github.repoCrowdStrike
// Falcon file events plus GitHub job logs and audit ingested into Next-Gen SIEM
(#event_simpleName=/Written$/ FileName=/^\.gdmp_(pat|patg|sweep)$/)
OR (#Vendor=github (@rawstring=/gdmp-pivot/ OR job_name="sync-host" OR action=/register_self_hosted_runner$/))
| case { FileName=* | signal := "gdmp file"; action=/register_self_hosted_runner$/ | signal := "runner registered (our check)"; * | signal := "job log" }
| default(field=[ComputerName, repo], value="n/a")
| groupBy([signal, ComputerName, repo], function=[min(@timestamp, as=first_seen), count(as=count)])Microsoft KQL
union isfuzzy=true
(DeviceFileEvents | where FileName in (".gdmp_pat", ".gdmp_patg", ".gdmp_sweep") | extend signal = "gdmp file", host = DeviceName, repo = "", t = Timestamp),
(GitHubJobLogs_CL | where Message contains "gdmp-pivot" or job_name == "sync-host" | extend signal = "job log", host = runner_name, t = TimeGenerated),
(GitHubAudit_CL | where action in ("repo.register_self_hosted_runner", "org.register_self_hosted_runner") | extend signal = "runner registered (our check)", host = "", t = TimeGenerated)
| summarize first_seen = min(t), count() by signal, host, repoMap the runner name: EDR knows the hostname, GitHub knows the name you gave the runner. Every GitHub selector in these four is ours, from index=github and logs-github.* to the job log fields, GitHubJobLogs_CL, GitHubAudit_CL and the runner registration action names, so check them against your stream.
The EDR side (index=edr with file_name, logs-endpoint.events.file-*, Falcon's Written events and DeviceFileEvents) follows the usual vendor shapes, but confirm those names too. Autoscaling registers runners all the time, so a registration alone means little.
Interpret before you escalate
Finding | What it likely means | Next step |
ci-sync branch whose commit adds only sync-check.yml | Matches Wiz's planting pattern | Check Hunt 2 for a run; if it ran or you cannot tell, rotate every secret the repo could read |
ci-sync run completed and deleted within a minute | Consistent with secrets printed and pulled | Treat as exposure, revoke the pushing token |
OIDC subject with ci-sync- in CloudTrail | Your workflow likely ran on a ci-sync branch | Confirm the repository and subject, then rotate role-reachable secrets and review the role |
Secrets Manager burst with no ci-sync role | Could be an app or deploy job | Baseline, then ask the owner |
Held sync-check run at action_required | Payload did not run | Delete the branch, revoke the token, never approve |
Runner registration alone | Could be autoscaling | Correlate with Hunt 2 before acting |
What to alert on
Run the AssumeRoleWithWebIdentity half of Hunt 1 as its own standing alert, because a ci-sync branch should not exist in your repositories at all, and this half needs no GitHub logs. The 20-call burst is the hunt, not the alert. A run deleted within a minute of finishing is how Gheist covers its tracks, so once you have excluded any cleanup automation that deletes runs quickly, a fast delete from the gheist user agent should page someone. Sweep Hunts 3 and 4 on a schedule.
What This Means for Your Team
For an MSP, the uncomfortable part is that the client's network never protected these secrets. One token in an infostealer log was enough, and the exfiltration ran through GitHub itself. Ask each client which secrets their repositories can read and which cloud roles trust GitHub OIDC, then scope those roles. If you cannot answer that today, start there, because nothing in Wiz's reporting will tell you whether you were hit.
Sources
Wiz, Rami McCarthy and Hila Ramati, Gheist: Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles (Oct 25, 2026): https://www.wiz.io/blog/gheist-attempted-scaled-abuse-of-stolen-github-tokens-stumbles
The Hacker News, Swati Khandelwal, Gheist Uses Stolen GitHub Tokens to Steal Private Repository Secrets via Actions Logs (Oct 26, 2026): https://thehackernews.com/2026/10/gheist-uses-stolen-github-tokens-to.html
GitHub Changelog, GitHub Actions holds potentially malicious workflows for approval (Jul 28, 2026): https://github.blog/changelog/2026-07-28-github-actions-holds-potentially-malicious-workflows-for-approval/
GitHub Docs, OpenID Connect reference: https://docs.github.com/en/actions/reference/security/oidc
GitHub Docs, Secure use reference: https://docs.github.com/en/actions/reference/security/secure-use
GitHub Docs, Reviewing the audit log for your organization: https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization
GitHub Docs, Configuring the retention period for GitHub Actions artifacts and logs in your organization: https://docs.github.com/en/organizations/managing-organization-settings/configuring-the-retention-period-for-github-actions-artifacts-and-logs-in-your-organization
GitHub Docs, Deployments and environments: https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments
Inception Security, GhostAction hunt: https://www.inceptionsecurity.com/post/ghostaction-security-audit-workflow-github-actions-hunt
Inception Security, LiteLLM 1.107.0 supply chain compromise hunt: https://www.inceptionsecurity.com/post/litellm-1-107-0-supply-chain-compromise-hunt
Inception Protection
Inception Protection is the Inception Security managed detection and response service, and it works with the Microsoft licenses you already have. We watch Defender endpoint and Entra sign-in data, where an infostealer on a developer laptop often shows first, and work through containment with your team.
Inception Foresight
Gheist starts with a stolen developer credential. To see whether your Microsoft environment would notice the infostealer behind it, try the free Inception Foresight M365 Assessment: https://www.inceptionsecurity.com/m365assessment.



