Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Wiz counted 13,500 Gheist branches today. I rebuilt its masking bypass in 15 lines and scanned my own repos.

Wiz Research published the Gheist write-up today (Oct 25), and the numbers are the kind that make you open a terminal: since September 15, someone has been using stolen GitHub tokens to plant a secret-dumping workflow in

Wiz Research published the Gheist write-up today (Oct 25), and the numbers are the kind that make you open a terminal: since September 15, someone has been using stolen GitHub tokens to plant a secret-dumping workflow into every repository each token can reach. Wiz recovered more than 13,500 planted branches from more than 850 compromised accounts, enterprise private repos included. The campaign is still running.

The mechanics are almost insultingly simple, which is why I wanted to verify them myself:

  1. Create a branch matching ^ci-sync-[0-9a-z]{6}$, commit one file — .github/workflows/sync-check.yml, message ci: sync config.
  2. The workflow runs on push and prints toJSON(secrets) — every secret the repo can access — piped through base32.
  3. Download the run log through the GitHub API, delete the run seconds later. No C2 domain, no outbound connection to block; the secrets leave through GitHub itself.

The masking bypass, rebuilt in 15 lines

The whole attack rests on one documented behavior: GitHub masks secret values in logs only when they appear verbatim. An encoded secret is just a string that doesn't match. I simulated the masking rule locally with a fake credential:

import base64, re
secret = "AKIA-EXAMPLE-7f3d9c2b-not-a-real-key"   # fake, for the demo
log_plain = f"token is {secret} ok"
log_b32   = "dump: " + base64.b32encode(secret.encode()).decode() + " end"

def mask(line, secrets):
    for s in secrets:
        line = line.replace(s, "***")
    return line
plain log after mask : token is *** ok
base32 log after mask: dump: IFFUSQJNIVMECTKQJRCS2N3GGNSDSYZSMIWW433UFVQS24TFMFWC223FPE====== end
recovered by attacker: (decodes back to the exact original — roundtrip identical: True)

That's it. That's the vulnerability: not a bug in the masker, but the masker working exactly as specified against an attacker who controls the encoding. Two more details from Wiz make it worse: permissions: {} restricts the job's GITHUB_TOKEN but does not restrict the secrets context, and the newer variant enumerates deployment environments in a matrix (environment: ${{ matrix.environment }}), so environment-scoped production secrets get printed too. A second job, added Oct 19, targets self-hosted runners and sweeps the host — /proc/*/environ, the Docker socket, cloud metadata, 31 token formats. That one is reportedly buggy (wrong runner key file, broken regex), which is the only funny sentence in the report.

One control did work: since July 28, GitHub holds first-time workflow runs in public repos for collaborator approval, and every public-repo Gheist run Wiz checked is sitting at action_required, never executed. The hold does not apply to private repos or GitHub Enterprise Server — which is where the successful exfiltrations happened.

Hunting my own account

Wiz's IOC list is unusually huntable, so I ran it against my own GitHub account (frankchu91) through the public API:

repos scanned:    12 (all public)
branches scanned: 26
matching ^ci-sync-[0-9a-z]{6}$: 0
repos with .github/workflows: 6 (9 workflow files in total)
content scan of all 9 files: clean, no markers

Clean — with the obvious caveat that this covers public repos and current branches only. Gheist deletes its runs, not its branches, but a branch deleted by the account owner (or wiped as a side effect — Wiz notes a related lure-repo campaign renaming repos and wiping 610 ci-sync branches) leaves nothing for this check to find. Absence here means "not currently planted," not "never touched."

A content scanner, because blob SHAs won't save you

Wiz publishes four workflow blob SHAs for the known variants — and then immediately undercuts them: "environment-matrix variants differ per repository." A per-repo-mutated payload has a per-repo SHA, so hash-matching catches the lazy copies and misses the tailored ones. The durable signals are content markers, so I wrote a small scanner around them and tested it on synthetic files reconstructed from Wiz's published IOC strings (marker text only — nothing that runs, no real secrets) plus benign controls:

MALICIOUS-PATTERN  v_plain.yml    ['secrets-tojson-dump', 'base32-encode']
MALICIOUS-PATTERN  v_matrix.yml   ['secrets-tojson-dump', 'base32-encode', 'env-matrix']
MALICIOUS-PATTERN  v_host.yml     ['gdmp-marker', 'self-hosted-job']
clean              b_deploy.yml   []
clean              b_ci.yml       []
clean              b_release.yml  []

3/3 variants flagged, 3/3 benign files clean. The benign controls matter: a normal deploy workflow legitimately references secrets.AWS_ACCESS_KEY_ID — but it passes them through env:, never echoes them, and never encodes anything. The line between "uses secrets" and "dumps secrets" is thin but it is a line: echo + secrets. context, any toJSON(secrets), any base32/base64 pipe in a workflow, and the gdmp-pivot markers are all things no legitimate workflow in my sample needed. Small numbers — six files — so treat this as a smoke test of the approach, not a benchmark.

What to actually do

From Wiz's guidance, condensed to the parts that follow from the mechanics above:

  • Hunt the branch pattern, not just the file: ci-sync- branches, runs deleted within a minute of completing, audit-log activity from the user agent gheist, and — the clever one — cloud audit logs showing OIDC role assumptions whose subject contains a ci-sync- branch. That last signal fires even if you collect no GitHub logs at all.
  • If a planted run executed, rotate everything that repo could reach: repo, org, and environment secrets. Deleting the branch does not un-print a log the attacker already downloaded.
  • Don't approve held sync-check runs in public repos; the approval hold is the control that worked.
  • Harden the boring way: required reviewers on environments holding production secrets, OIDC trust policies scoped to protected branches, short-lived fine-grained tokens over long-lived PATs — the tokens feeding this campaign came from infostealer logs and earlier supply-chain compromises.

The uncomfortable summary is that Gheist isn't exploiting GitHub so much as using it — push triggers, log storage, log API, run deletion are all features. The only layer that failed closed was a human approval gate, and only in public repos. If your private repos can print their own secrets into a downloadable log, the distance between "feature" and "exfiltration channel" is one stolen token and about six random characters.

Sources: Wiz Research, "Gheist: Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles" (wiz.io, published Oct 25, 2026). The masking simulation, the account scan, and the marker scanner are my own runs today; synthetic payloads were reconstructed from Wiz's published IOC markers only and were never executed or pushed anywhere.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.