← Back to all posts
Published on · by Renaud Deraison

The stolen token needed no server

Wiz Research has traced Gheist, an automated campaign that takes GitHub tokens from infostealer logs and old supply-chain leaks and plants a secret-dumping workflow in every repository each token can reach: more than 13,500 branches from more than 850 accounts. The secrets leave through GitHub's own API, so there is no attacker server to block. In Bromure Agentic Coding, the GitHub token a stealer finds on a developer machine is a stand-in that GitHub rejects from anywhere else, and every write from inside the machine waits for a dialog on your Mac.

Gheist has no command server. GitHub runs the payload, GitHub writes the secrets into a log, and GitHub serves the log to whoever holds the token.

On October 25, Wiz Research published its analysis of Gheist, a campaign it has tracked since September 15. An automated tool takes a stolen GitHub token, lists every repository that token can reach, and plants a workflow that prints the repository's secrets. Wiz has recovered more than 13,500 planted branches from more than 850 compromised accounts, and as of October 24 the campaign was still running and still changing. The Hacker News covered it yesterday.

The tool needs only one input, and Wiz says where those come from: the tokens match infostealer logs and tokens exposed in earlier supply-chain compromises, such as the March compromise of the Trivy scanner. Wiz's advice to every victim includes this line: check the token owners' devices for infostealers.

How Gheist works

For each repository, the tool creates a branch named ci-sync- plus six random characters and commits one file, .github/workflows/sync-check.yml, with the message ci: sync config. The commit is unsigned and attributed to the token's owner. Its API requests carry the user agent gheist, which gave the campaign its name.

The push starts the workflow. The job prints its environment variables and the full secrets context into the run log, both encoded in base32. Wiz explains why that works: "GitHub masks secret values in logs only when they appear verbatim." Base32 output doesn't match the stored values, so nothing is masked. A matrix over the repository's deployment environments pulls in environment-scoped secrets, which are often the production ones.

Then the tool downloads the log through the GitHub API and deletes the run seconds later. In Wiz's words: "Because the secrets leave through GitHub's own API, there is no attacker domain or outbound connection to block."

The tool kept improving. One variant added a six-minute timeout, the environment matrix and per-repository payload changes within hours of its first run on September 28. On October 19 it gained a job aimed at self-hosted runners that hunts for 31 token types, cloud instance credentials and the Kubernetes service account token.

From a stolen token to a repository's secrets1 · A stolen tokenfrom an infostealer logor a supply-chain leak850+ accounts2 · Branch per repositoryci-sync-xxxxxxadds sync-check.yml13,500+ branches3 · Secrets into the logtoJSON(secrets) | base32masking misses encodedvalues4 · Download, then deletelog fetched via GitHub's API,run deleted seconds later;no attacker hostPublic repositories · held by GitHubsince July 28, flagged runs wait for a collaborator;every public Gheist run Wiz checked sat ataction_requiredPrivate repositories and GHES · ranaudit logs show ci-sync-* runs completing,then deleted; CloudTrail shows AWS rolesassumed from those branches
Gheist's chain, as Wiz describes it. A token from an infostealer log or an earlier supply-chain leak lets an automated tool push a ci-sync- branch with one workflow file to every repository the token reaches. The workflow prints every secret in base32, past GitHub's log masking, and the tool downloads the log through GitHub's API and deletes the run. No attacker server is involved at any step.

Where GitHub's new rule stops

Since July 28, GitHub has held workflow runs it flags as malicious in public repositories until a collaborator approves them. Every public Gheist run Wiz checked was stuck at action_required. "The control worked, and the malicious payloads never ran."

The hold covers public repositories on github.com only. In private repositories and on GitHub Enterprise Server, Wiz found audit logs showing ci-sync-* runs that completed and were then deleted, and CloudTrail records of organization workflows assuming AWS roles from those branches.

The token comes from a developer's machine

Each Gheist branch needs a working token, and Wiz traced those tokens back to stealer logs and leaked build tools. On a developer's laptop, the gh CLI login, a personal access token in ~/.git-credentials, a GH_TOKEN in a shell profile: an infostealer copies all of them in one pass. Coding agents have made these tokens more common, because an agent that pushes branches and opens pull requests needs one in reach at all times.

The theft can come from a tool you trust. On October 19, an attacker who had held an OVHcloud developer's GitHub account for months swapped the binaries in five releases of the OVHcloud CLI, v0.11.0 through v0.15.0, for builds that, in OVHcloud CEO Octave Klaba's words, "retrieve secrets from the disk." BleepingComputer reported that the macOS, Linux and Windows builds were all affected. Klaba's advice: "If you are affected, please change all the secrets on your disk."

A stolen token doesn't have to reach a campaign like Gheist the same day. Stealer logs are traded, and a token stays useful until someone revokes it.

The same token on a Bromure machine

Bromure Agentic Coding runs Claude Code, Codex, Grok, Kimi and omp in Ubuntu VMs on your Mac. Real credentials stay on the host, and the agent works with stand-ins. Follow a GitHub token through Gheist's chain on one of those machines.

The token on disk is a stand-in

Add your GitHub token to the machine under Credentials. At boot, Bromure writes a fake into the VM: ghp_ plus 36 characters, the shape gh expects, in GH_TOKEN, in gh's configuration and in ~/.git-credentials. The agent's git push and gh pr create work as before. When a request leaves the VM for github.com, the proxy on the Mac swaps the fake for the real token, and only for that host.

Now run the trojanized CLI inside that VM, the way an agent would after following a project's install instructions. It reads the disk, and every GitHub value on it is the stand-in. The same goes for the rest of the developer's usual loot. ~/.aws/config points at a helper that hands out a fake secret key, and the proxy re-signs AWS requests with the real one on the Mac. The VM has no SSH private keys to read: SSH_AUTH_SOCK leads to an agent on the host, which signs for the VM and never gives up the key.

Gheist can't use the stand-in

The Gheist tool runs on the attacker's own infrastructure. It calls api.github.com with the token it bought and expects to see repositories. With a Bromure stand-in, GitHub answers 401 Bad credentials. Bromure derives the stand-in from the real token with a salt that stays on your Mac, and only that Mac's proxy knows which real token it stands for. The tool can't list a single repository, so it never creates a branch.

Gheist's whole method is to ask what a token can reach. From outside the VM it was written into, a Bromure stand-in reaches nothing.

A stand-in headed for a stealer trips an alarm

The stealer still has to send what it found somewhere, and from a Bromure VM every HTTPS connection goes through the proxy on the Mac. The proxy scans each outbound request, headers and body, for stand-ins headed to a host they weren't minted for. A ghp_ stand-in bound for anything other than GitHub is blocked with HTTP 451 before a byte leaves the Mac. The VM pauses, and an alert reads This environment may have been compromised, naming the credential, the host it belongs to and the host it was sent to.

You can shut the machine down, or save its disk and home folder for investigation. A machine marked compromised won't boot again until its disk and home are wiped, so the stealer doesn't survive the restart.

Set Unmatched traffic to Deny in the machine's Guardrails pane and allow the hosts the project needs, and the stealer's upload host is refused outright, encrypted archive or not. The refused connection is a row in the Security Timeline.

Writes from inside the machine wait for you

Suppose the malware skips the theft and uses the stand-in where it works, from inside the VM, through the proxy. It would have to do what Gheist does: create a branch and commit a workflow.

New Bromure machines set GitHub's write policy to Prompt before write. Reads pass. A git push or a REST write to github.com pauses on a dialog on your Mac that shows the operation verbatim:

Allow write on "GitHub" from workspace "web-app"?
POST /repos/<owner>/<repo>/git/refs

A ci-sync- branch you didn't ask for, appearing while you were looking at a pull request, is easy to refuse. Don't allow sends the agent the same error a blocking policy would, and Allow once grants nothing beyond that one write.

Laptop: a live token on diskStealer reads the diskgh login, ~/.git-credentials, GH_TOKEN: the real tokenStealer log, then the gheist toolworks from any machine on the internetapi.github.com accepts itci-sync- branches in every repository it reaches;secrets leave through GitHub's own APIBromure: a stand-in in the VMAgent VM (Ubuntu)gh config, ~/.git-credentials, GH_TOKEN = ghp_ stand-inHost proxy on the Macreal token swapped in for github.com only;stand-in sent elsewhere: 451, VM paused; writes promptFrom the attacker's infrastructurethe stand-in is not a GitHub token;api.github.com rejects it, nothing is planted
Gheist on a laptop and on a Bromure machine. On a laptop, a stealer copies a live GitHub token from disk and the gheist tool uses it from anywhere. On a Bromure machine, the VM holds a ghp_ stand-in; GitHub rejects it from the attacker's infrastructure, the proxy blocks it on its way to the stealer's host and pauses the VM, and any write from inside the VM waits for a dialog on the Mac.

Rotation happens in one place

Wiz's first response step is to rotate every secret an affected repository could reach and to revoke the tokens that pushed the commits. "Deleting the run or the branch doesn't undo the exposure."

On a Bromure Mac, the developer side of that rotation is one field. The real GitHub token lives in one encrypted entry on the host. Paste the new one into the machine's Credentials pane and the next launch writes the matching stand-in; there are no copies to hunt down in shell profiles, .env files or agent configs. For a key stored as a 1Password reference (op://vault/item/field under Other API keys), Bromure re-resolves it every two minutes, so a rotation in 1Password reaches running machines without a reboot.

The vault itself sits under a key in the macOS Data Protection Keychain, scoped to Bromure's signing identity and never synced. A stealer that copies Bromure's folder off the Mac gets ciphertext.

Let an agent run the hunt, read-only

Wiz's hunting steps are tedious work across many repositories, which makes them good work for an agent: list every branch matching ^ci-sync-[0-9a-z]{6}$, find the commits that add only sync-check.yml with the message ci: sync config, pull the audit log for runs deleted within a minute of completing and for the gheist user agent.

Give the job to a Bromure machine whose GitHub write policy is set to Read-only. The agent can read every repository and audit log the token allows, and any push or REST write is refused on the Mac before it reaches GitHub. If the agent checks out a ci-sync- branch to look at it, including one of the branches Wiz saw re-pushed with PolinRider loaders, whatever runs does so in a VM full of stand-ins. Each credential swap shows up as a Credential brokering row in the Security Timeline, so you can see afterwards which hosts the hunt touched.

Stealers find stand-ins

GH_TOKEN, gh's config and ~/.git-credentials hold a ghp_ fake. The real token stays encrypted on the Mac.

Gheist gets a 401

Off the machine, the stand-in isn't a GitHub token. The tool can't list repositories, so it plants nothing.

Leaks pause the VM

A stand-in headed anywhere but GitHub is blocked with a 451, and the VM pauses until you decide what happens to it.

Writes ask first

New machines prompt before every push and REST write to GitHub, with the exact request on screen. A hunt can run read-only.

Take the token off the laptop

GitHub's approval hold worked on public repositories, and Wiz's hardening list is the right one: required reviewers on production environments, OIDC trust scoped to protected branches, GitHub Apps or short-lived fine-grained tokens instead of personal access tokens. All of it narrows what a stolen token can do once someone holds it.

Bromure Agentic Coding works one step earlier. Your agents push branches and open pull requests all day, and the token they use never sits on a disk a stealer can read. Gheist can only plant workflows with tokens that still work, and the one on a Bromure machine works only through that machine's own proxy.

For how the same stand-ins hold up against a stealer built for coding-agent files, read The stealer packed the stand-ins. For a single stolen GitHub token that ended in a poisoned release, read The agent's token was the owner's.