top of page

Gheist GitHub Token Attack: Hunt ci-sync Secret Dumps

Writer: Inception Security
Inception Security
16 hours ago
12 min read
Inceptor and Glitch comic about a GitHub ci-sync branch that prints secrets

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 - count

Elastic


FROM logs-aws.cloudtrail-*, logs-github.*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY data_stream.dataset
| SORT count DESC

CrowdStrike


// 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_ desc

Every 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>=20

Elastic


// 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=20

CrowdStrike


// 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 >= 20

How 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 repo

Elastic


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.repo

CrowdStrike


// 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 repo

The 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, repo

Elastic


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.repo

CrowdStrike


// 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, repo

Map 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



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.


bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page