Gheist: Stolen-Token GitHub Actions Secret Dump

Wiz tracks Gheist since 2026-09-15. A tool uses stolen GitHub tokens to plant a one-file workflow on a ci-sync branch that prints base32-encoded secrets into the Actions log, then downloads the log and deletes the run. Built on the Wiz Research report and five derivative write-ups.
Threat
Gheist (Wiz)
Severity
HIGH · CI/CD SECRET THEFT
Type
Stolen-token abuse, workflow secret exfiltration
Access Vector
Stolen GitHub tokens (infostealer logs, earlier breaches)
Version
v0.1 (Draft) · 2026-10-26
Author
HuntPack
Confidence
High on mechanics (single primary source); attribution not established
01

Executive Summary

Attacker objective. Harvest CI/CD secrets at scale. Operators feed stolen GitHub tokens (from infostealer logs and earlier supply-chain breaches, with the March 2026 Trivy compromise named as one source) into a tool that, for every repository a token can reach, creates a ci-sync- branch and commits one workflow file. [#1] [#2]

How it works. The push triggers the workflow, which prints the job environment and every secret the job can read, base32-encoded so GitHub log masking does not match. The tool then downloads the run log through the GitHub API and deletes the run seconds later. The secrets leave through GitHub itself, so there is no attacker server to block. One variant runs a job per deployment environment to reach environment-scoped, often production, secrets. [#1] [#2]

Scale and status. Wiz recovered more than 13,500 planted branches from more than 850 compromised accounts and has tracked the campaign since 2026-09-15. As of 2026-10-24 it was still active and about two thirds of the accounts were gone from GitHub. GitHub's hold on suspicious workflow runs (since 2026-07-28) stopped every public-repository run Wiz checked, but it covers public github.com repositories only, not private repositories or GitHub Enterprise Server, where runs completed and secrets were taken. In one enterprise case cloud credentials from the same organisation were used hours later to enumerate AWS Secrets Manager. [#1] [#2] [#3]

Self-hosted runner variant. From about 2026-10-19 one tool version pushes a second job that searches the runner machine for credentials (31 token formats, process environments, instance-role credentials, the Docker socket, runner credential files, the Kubernetes service-account token). Wiz calls it buggy and reports no successful run. [#1] [#2]

What CrowdStrike can and cannot see. Almost all of the attack happens inside GitHub (branch creation, workflow run, log download, run deletion) and in cloud audit logs. Falcon sees only the self-hosted runner hosts and developer endpoints. This pack gives you Falcon queries for those hosts and, in Section 7, native checks for the part Falcon cannot see. The Coverage Map in Section 15 marks the GitHub-side steps as gaps.

Intel quality. Wiz is the only originating source. The other outlets repeat or summarise it, so every indicator is single-sourced and ships as detect or hunt, never prevent.

Defender priority: deleting a ci-sync branch or run does not undo the exposure. For any repository a stolen token could reach, rotate every repository, organisation and environment secret, revoke the token that pushed, check its owner's device for an infostealer, and treat any self-hosted runner that ran a ci-sync branch as compromised.

02

Source Review & Web Hunter Notes

Six sources were fetched and saved verbatim as text snapshots for the provenance gate. Wiz is the primary. Every other outlet is derivative: none adds independent technical telemetry, so no indicator has two independent sources.

#TierSourceKey findingCarry forward
1T1Wiz Research blog: Gheist, Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles (snapshot 01)Primary. Campaign name and origin, attack chain, workflow content, two tool variants, self-hosted job stages and bugs, GitHub hold limits, overlaps with other campaigns, hunt and response guidance, the published indicator list.yes
2T2The Hacker News, 2026-10-26 (snapshot 02)Independent write-up of Wiz with the same indicator list. Adds GitHub's own wording on the hold and on write access reading secrets. Not independent of Wiz for facts about the campaign.yes
3T1GitHub changelog, 2026-07-28: Actions holds potentially malicious workflows for approval (snapshot 06)Vendor statement that the hold applies to public repositories on github.com only and that GitHub Enterprise Server does not have it.yes
4T3Safeguard blog, 2026-10-26 (snapshot 03)Derivative long-form summary of Wiz. Used only for cross-checking the indicator list and the OIDC subject format. Contains vendor marketing, which was ignored.partial
5T3SecurityOnline (snapshot 04)Short derivative summary. It paraphrases the runner job slightly differently from Wiz (for example "mounted Docker containers"); where it differs, Wiz wins.partial
6T3Mallory story aggregator (snapshot 05)Aggregator timeline built from Wiz. Used to confirm the 2026-10-19 separator and self-hosted dates.partial

Decisions

  • Wiz does not show a publication date in the saved text; secondary coverage dates it 2026-10-25 and The Hacker News ran it 2026-10-26. The pack states "as of 2026-10-24" where Wiz does.
  • Wiz names GhostAction, Megalodon, PolinRider and the Trivy compromise as related or adjacent. This pack ships none of their indicators and does not treat them as the same campaign. Wiz says Gheist is distinct from GhostAction.
  • The four git blob SHAs are git object ids (SHA-1 of a blob), not endpoint file hashes. They cannot be imported into Falcon IOC Management and are labelled that way throughout.
  • Wiz does not publish the content of the helper files, the commands inside the sync-host job, or any attacker network infrastructure. Anything this pack says beyond those facts is a labelled heuristic.
  • One further outlet (RuntimeWire) returned only a headline-level summary and was neither saved nor cited. No fetched page contained instructions aimed at an AI agent.
03

Hunt Brief & Attack Chain

Seven hypotheses, ordered by fidelity. Telemetry is named in plain terms first, then by platform.

  1. H1 (high): a self-hosted runner executed the sync-host job. Evidence: command lines naming the .gdmp_ helper files or the gdmp-pivot header (Q1) or helper file writes (Q2). T1552.001. FP: none expected. Process creation with command line (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents).
  2. H2 (high): a shell on a runner or developer host encoded the environment with base32 at 120 columns (Q3). T1027. FP: rare scripts.
  3. H3 (medium): a runner host performed a multi-class credential sweep (Q5) or started a container with the host root mounted (Q6). T1003.007, T1552.005, T1552.007, T1611. FP: automation and diagnostics.
  4. H4 (medium): a host fetched or pushed a ci-sync branch (Q4). T1078.004, T1213.003. FP: responders.
  5. H5 (low): a developer endpoint had its GitHub token stores read or its token pasted into a command (Q7, Q8). T1528. FP: backup and credential helpers.
  6. H6 (native, high): planted branches exist, with the one-file commit and a matching blob id, in any repository the token reached (N1, N5). Outside endpoint telemetry.
  7. H7 (native, high): runs on ci-sync branches were deleted within a minute of finishing, the gheist user agent appears in audit logs, or a cloud role was assumed with a ci-sync subject (N2, N3). Outside endpoint telemetry.

Attack chain

StepWhat happensTelemetry that could see itHunt angle
1Tokens are collected from infostealer logs and earlier supply-chain breaches [#1]Process creation with command line on the token owner's device (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents). The theft itself is partly outside endpoint telemetry.Q7, Q8; check owner devices for infostealers
2Tool cycles through tokens of many users and automation accounts; clones private repositories first [#1] [#2]GitHub audit log and token usage (outside endpoint telemetry)N2
3Creates ci-sync-xxxxxx branch, commits one unsigned workflow file with message ci: sync config [#1]GitHub repository state (outside endpoint telemetry); process creation only if a host runs git or gh for itN1, N5, Q4
4Push triggers the workflow, which prints env and secrets in base32 [#1]Job log in GitHub (outside endpoint telemetry); on self-hosted runners, process creation for base32 (as above)Q3, N2
5Tool downloads the run log through the API and deletes the run seconds later; user agent gheist [#1]GitHub audit log only (outside endpoint telemetry); no attacker network address exists to blockN2
6Self-hosted variant (from about 2026-10-19): second job sweeps the runner host [#1] [#2]Process creation with command line; file write; outbound connection to cloud metadata (CrowdStrike: NetworkConnectIP4. SentinelOne: IP Connect. Defender: DeviceNetworkEvents)Q1, Q2, Q5, Q6, Q9
7Follow-on: cloud credentials from the same organisation used to enumerate AWS Secrets Manager [#1] [#2]Cloud provider audit log (outside endpoint telemetry)N3
04

Consolidated IOC Table

Every row is single-sourced to Wiz (the other outlets repeat it), so nothing ships as prevent. There is no network indicator: the campaign has no attacker domain or IP. The git blob SHAs identify a file inside a repository, not a file on an endpoint, and must not be imported as file hashes. Indicators published for related campaigns are deliberately not included.

TypeValueConfidenceActionContext
User agentgheisthighhuntSent by the tool on GitHub API requests; GitHub audit log or streaming only. [#1] [#2]
Branch pattern^ci-sync-[0-9a-z]{6}$highhuntTool A suffix hexadecimal, Tool B base36. [#1] [#2]
Commit messageci: sync confighighhuntCommit adds only the workflow file; unsigned; authored as the token owner. Generic wording, so combine with the branch pattern. [#1] [#2]
Workflow file.github/workflows/sync-check.ymlhighhuntWorkflow name sync-check. Second job in the self-hosted version is sync-host. [#1] [#2]
Payload stringtoJSON(secrets) }}' | base32 -w 120highhuntIn the workflow file content. [#1] [#2]
Log header===== gdmp-pivot:highdetectWritten by the self-hosted job before each stage name. [#1] [#2]
Output markerLAFA====mediumhuntWhat the base32 of a single X prints; the separator added around 2026-10-19, likely for the operator's log parser. Appears in job logs. [#1]
Filename.gdmp_pathighdetectSelf-hosted job helper file. Content not published. [#1] [#2]
Filename.gdmp_patghighdetectSelf-hosted job helper file. [#1] [#2]
Filename.gdmp_sweephighdetectSelf-hosted job helper file. [#1] [#2]
Git blob SHA-1f3da2b8b357dcda02fd455d314be2a95007516a6highhuntPlain workflow. A git object id, not a file hash. [#1] [#2]
Git blob SHA-1d09535857a741bbb976bdc084a9a021713133291highhuntVersion with timeout-minutes. [#1] [#2]
Git blob SHA-11dc5af9cce950aad1eb1546ecce8565b117540c4highhuntVersion with the base32 separator. [#1] [#2]
Git blob SHA-1c665b778b2d822453293003e7ca0bbb2cb08e39dhighhuntVersion with the self-hosted job. Environment-matrix variants differ per repository and have no fixed id. [#1] [#2]
05

Affected Surface & Telemetry Matrix

SurfaceTelemetryPriorityGap risk
Self-hosted runner hosts (Linux most likely; any OS with a registered runner)Process creation with command line, image path and parent (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents); file write for scripts and ELF files (CrowdStrike: NewScriptWritten, ElfFileWritten. SentinelOne: File Creation. Defender: DeviceFileEvents); outbound connection (CrowdStrike: NetworkConnectIP4. SentinelOne: IP Connect. Defender: DeviceNetworkEvents)HighPlain-data helper files may not raise a file-write event; shell built-ins such as echo create no process; reads of /proc are not logged as events
Developer endpoints holding GitHub tokensProcess creation with command line (as above)MediumInfostealer file reads are usually in-process and not logged; Q7 and Q8 catch only command-line handling
GitHub-hosted runnersNone. No customer sensor runs thereHighOutside endpoint telemetry. The base32 dump on a hosted runner is visible only in the GitHub job log
GitHub control plane (branches, commits, runs, deletions, API user agent)GitHub audit log and audit-log streamingHighOutside endpoint telemetry. This is where the campaign is mostly visible; org-level audit log needs the right plan, and deleted runs leave audit events only
Cloud audit logs (OIDC role assumption, Secrets Manager enumeration)Cloud provider audit log, for example AWS CloudTrailHighOutside endpoint telemetry. Only helps when OIDC is used and the log is collected
06

ATT&CK Mapping

TacticTechniqueObserved behaviorQuery or control
Initial AccessT1078.004 Valid Accounts: Cloud AccountsStolen GitHub tokens of users and automation accounts are used directlyN2; Hardening T3
Credential AccessT1528 Steal Application Access TokenTokens taken from infostealer logs and earlier breachesQ7, Q8; Hardening T3
CollectionT1213.003 Data from Information Repositories: Code RepositoriesPrivate repositories cloned before the workflow is plantedN2; Hardening T3
ExecutionT1059.004 Unix ShellWorkflow steps and the self-hosted job run shell commandsQ1, Q3, Q9
Defense EvasionT1027 Obfuscated Files or Informationbase32 encoding so log masking does not matchQ3
Defense EvasionT1070 Indicator RemovalWorkflow run deleted seconds after the log is downloadedN2
Credential AccessT1552.001 Credentials in FilesSelf-hosted job searches for token formats, private keys, .env files, Terraform state, shell historyQ1, Q2, Q5
Credential AccessT1003.007 Proc FilesystemEnvironment of every process read from /procQ5
Credential AccessT1552.005 Cloud Instance Metadata APIAWS instance-role credentials and GCP or Azure metadata requestedQ5; Hardening T4
Credential AccessT1552.007 Container APIKubernetes service-account token readQ5
Privilege EscalationT1611 Escape to HostContainer started with the host root filesystem mounted via the Docker socketQ6; Hardening T4
ExfiltrationT1567 Exfiltration Over Web ServiceSecrets leave through the GitHub run log and API, no attacker serverN2 (outside endpoint telemetry)
DiscoveryT1555.006 Credentials from Password Stores: Cloud Secrets Management StoresAWS Secrets Manager enumerated with cloud credentials from the same organisationN3 (outside endpoint telemetry)
07

Native Audit-Log Hunts

These checks need no Falcon query. They cover the GitHub and cloud side that endpoint telemetry cannot see. All commands are read-only.

N1 · Find planted branches

Where: a workstation with the GitHub CLI authenticated as someone who can read the repositories

# One repository
gh api "repos/OWNER/REPO/branches" --paginate -q '.[].name' | grep -E '^ci-sync-[0-9a-z]{6}$'
# Every repository in an organisation (set ORG first)
gh repo list "$ORG" --limit 2000 --json nameWithOwner -q '.[].nameWithOwner' | while read -r r; do
  gh api "repos/$r/branches" --paginate -q '.[].name' 2>/dev/null | grep -E '^ci-sync-[0-9a-z]{6}$' | sed "s|^|$r |"
done

An empty result is not proof. Wiz reports that a rename wave on 2026-10-06 wiped 610 ci-sync branches as a side effect, and operators can delete branches. Use N2 as well.

N2 · GitHub audit log

Where: organisation or enterprise audit log, or the SIEM you stream it to

# Look for (names vary by plan; confirm in your own audit-log schema):
#   - any event whose user agent is: gheist
#   - workflow runs on branches matching ci-sync-* that were deleted within a minute of finishing
#   - events: workflows.completed_workflow_run and workflows.delete_workflow_run for those branches
# Read-only API example for Enterprise Cloud organisations:
gh api --paginate "orgs/$ORG/audit-log?phrase=action:workflows.delete_workflow_run&per_page=100" \
  | jq -c '.[] | select(tostring | test("ci-sync-|gheist"; "i"))' 

This is the primary place to find Gheist. Which events carry the user agent depends on your plan and on whether you stream the log.

N3 · Cloud audit logs for OIDC role assumptions

Where: AWS CloudTrail (or the Azure and Google Cloud equivalents)

# Wiz: role assumptions or token exchanges whose subject includes a ci-sync- branch show that
# Actions ran on an attacker branch, even if you collect no GitHub logs.
# AWS example: AssumeRoleWithWebIdentity events where the web identity subject contains ci-sync-
aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
  --start-time 2026-09-01T00:00:00Z --query 'Events[].CloudTrailEvent' --output json \
  | jq -r '.[] | fromjson | select(tostring | test("ci-sync-")) | [.eventTime, .requestParameters.roleArn] | @tsv'
# Then review what the assumed session did next, especially Secrets Manager ListSecrets and GetSecretValue.

Repositories created after 2026-07-15 use an immutable default subject format with owner and repository ids, so search for the ci-sync- substring, not an exact subject. When a job uses a deployment environment the subject carries the environment instead of the branch.

N4 · Self-hosted runner host check

Where: each runner host registered to an affected repository or organisation

sudo find / -xdev \( -name '.gdmp_pat' -o -name '.gdmp_patg' -o -name '.gdmp_sweep' \) 2>/dev/null
sudo grep -rl 'gdmp-pivot' /path/to/actions-runner/_diag 2>/dev/null

Also search the runner job history in GitHub for any job on a ci-sync-* branch and the job logs for gdmp-pivot. A single hit means the host and its cloud instance role are compromised.

N5 · Confirm a planted commit

Where: a clone of an affected repository

git fetch origin 'refs/heads/ci-sync-*:refs/remotes/origin/ci-sync-*'
for b in $(git branch -r --list 'origin/ci-sync-*'); do
  echo "== $b"
  git rev-parse "$b:.github/workflows/sync-check.yml"      # git blob SHA-1, compare with Section 10
  git log -1 --format='%H | %an | sig=%G? | %s' "$b"        # unsigned, token owner as author, "ci: sync config"
  git diff --stat "origin/HEAD...$b"                       # expect exactly one new file
done

A match on blob id, message, unsigned status and a single changed file is a confirmed plant. A different blob id with everything else matching is probably an environment-matrix variant. The author named on the commit is the account whose token was stolen.

N6 · Held workflow runs

Where: public repositories on github.com

gh run list --repo OWNER/REPO --status action_required --json databaseId,headBranch,workflowName,createdAt

Wiz reports every public Gheist run it checked sat at action_required. Do not approve a held sync-check run and do not run code from a ci-sync branch.

08

Hunt Queries: CrowdStrike CQL, SentinelOne PowerQuery and Defender XDR KQL

Show all queries in
Pick your tenant's cloud first — every "Open in Falcon" button below uses this selection.

Falcon sees self-hosted runner hosts and developer endpoints only. It does not see GitHub-hosted runners, the GitHub API, branch creation, run deletion or cloud audit logs, so these queries cannot confirm or rule out the core of the campaign. Run Q9 first to list the runner hosts, then Q1 to Q6 against them. Where a file or metadata name is not in the Wiz report, the query says so. Event names were taken from the Falcon event reference; validate the sensor-classified file events in your tenant.

Q1 · Gheist helper files and log header in command lines (self-hosted runner)
CONF highFP lowCOST low

Looks for: Linux or macOS process command lines that name the three self-hosted-job helper files or the gdmp-pivot log header. Wiz says the sync-host job writes these files and logs each stage under that header, so any command naming them is the job running on a host your sensor covers.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: equivalent-with-schema-assumptionsProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: A command line the agent did not capture, or a process it did not see start, is not matched.
  • PowerQuery assumes: tgt.process.cmdline holds the full command line of the new process.
  • PowerQuery assumes: src.process.user is the account that launched the new process.
  • KQL – Defender XDR: A process the sensor did not observe starting is not captured.
  • KQL – Defender XDR assumes: ProcessCommandLine holds the full command line of the created process.
// HUNT: Gheist self-hosted job helper files and log header in command lines
// MITRE: T1552.001, T1059.004
// CONF: high  FP: low  COST: low
// REQUIRES: sensor on self-hosted runner hosts (process creation with command line)
// FALSE POSITIVES: an analyst running find or grep for these strings during triage
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| CommandLine=/\.gdmp_(pat|patg|sweep)\b|gdmp-pivot/i
| table([@timestamp, aid, ComputerName, UserName, FileName, CommandLine, ParentBaseFileName])
Q2 · Gheist helper file creation on a runner host (sensor-classified files only)
CONF highFP lowCOST low

Looks for: File-write events for the basenames .gdmp_pat, .gdmp_patg and .gdmp_sweep. Coverage limit: Falcon raises these two events only for files the sensor classifies as a script (NewScriptWritten, documented mainly for Windows script types, so confirm it fires on Linux in your tenant) or an ELF executable (ElfFileWritten). Wiz does not say what the helper files contain, so a quiet result proves nothing. Treat Q1 and the native find in N4 as the primary checks.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: approximate-analyticProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: A path with backslash separators (Windows) is not matched.
  • PowerQuery: A helper written over an existing file is a modification event and is not matched.
  • PowerQuery assumes: tgt.file.path holds the full path with forward-slash separators on Linux and macOS.
  • PowerQuery assumes: A file created by a script write appears as a File Creation event.
  • KQL – Defender XDR: A helper written over an existing file is recorded as a modification and is not matched.
  • KQL – Defender XDR assumes: FileName holds the file's base name only.
  • KQL – Defender XDR assumes: A script or ELF write is recorded as a FileCreated action.
// HUNT: Gheist helper file written on a host
// MITRE: T1552.001
// CONF: high  FP: low  COST: low
// REQUIRES: file-write telemetry for scripts and ELF files; coverage depends on sensor file classification (validate in tenant)
// FALSE POSITIVES: none expected; the names are specific to the Wiz-reported job
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(NewScriptWritten|ElfFileWritten)$/
| FileName=/^\.gdmp_(pat|patg|sweep)$/i
| table([@timestamp, aid, ComputerName, FileName, TargetFileName, ContextBaseFileName])
Q3 · base32 at width 120 started by a shell (secret-dump encoding)
CONF highFP lowCOST low

Looks for: The base32 utility run with a 120-column wrap by a shell. Every reported workflow version pipes the environment and the secrets context through base32 -w 120 to defeat GitHub log masking. On a self-hosted runner the job's shell step is a child of bash, so the encoder shows up as a process. Hosted runners are not covered.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: equivalent-with-schema-assumptionsProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: A base32 run whose creator is not bash, sh or dash is not matched.
  • PowerQuery assumes: src.process.name is the creator of the new process on a Process Creation event and holds the bare shell name.
  • PowerQuery assumes: src.process.user is the account that launched the new process.
  • KQL – Defender XDR: A base32 run whose creator is not bash, sh or dash is not matched.
  • KQL – Defender XDR assumes: InitiatingProcessFileName holds the bare shell name on Linux and macOS sensors.
// HUNT: base32 with 120-column wrap spawned by a shell
// MITRE: T1027, T1552.001
// CONF: high  FP: low  COST: low
// REQUIRES: Linux or macOS sensor on self-hosted runners or developer hosts (process creation with command line)
// FALSE POSITIVES: a developer or script that happens to wrap base32 output at 120 columns
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| FileName=/^base32$/i
| CommandLine=/-w\s*120|--wrap[= ]+120/
| ParentBaseFileName=/^(bash|sh|dash)$/
| table([@timestamp, aid, ComputerName, UserName, FileName, CommandLine, ParentBaseFileName])
Q4 · git or gh referencing a ci-sync branch or the planted commit message
CONF mediumFP mediumCOST low

Looks for: git or gh command lines that name a ci-sync-xxxxxx branch or the commit message ci: sync config. The Gheist tooling runs on attacker infrastructure and may use the GitHub API instead of the git CLI, so this fires mainly on a compromised developer or runner host, a script that replays the plant, or an analyst fetching evidence. FP: the commit-message alternative is generic; drop it if it is noisy and keep the branch pattern.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: equivalent-with-schema-assumptionsProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: A git operation run through a wrapper whose image name is not git or gh is not matched.
  • PowerQuery assumes: tgt.process.name holds the bare image name of the new process.
  • PowerQuery assumes: src.process.user is the account that launched the new process.
  • KQL – Defender XDR: A git operation run through a wrapper whose image name is not git or gh is not matched.
  • KQL – Defender XDR assumes: FileName holds the bare image name of the created process.
// HUNT: git or gh command lines naming a ci-sync branch or the planted commit message
// MITRE: T1078.004, T1213.003
// CONF: medium  FP: medium  COST: low
// REQUIRES: process creation with command line on developer endpoints and runner hosts
// FALSE POSITIVES: incident responders fetching ci-sync branches as evidence; a team that really uses the message "ci: sync config"
// TUNING: remove the "ci: sync config" alternative if it is noisy; exclude the IR workstation group by ComputerName after the first review
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| FileName=/^(git|gh)(\.exe)?$/i
| CommandLine=/\bci-sync-[0-9a-z]{6}\b|ci: sync config/i
| table([@timestamp, aid, ComputerName, UserName, FileName, CommandLine, ParentBaseFileName])
Q5 · Credential-sweep behavior by shell children on a Linux host (two or more classes)
CONF mediumFP mediumCOST medium

Looks for: Linux shell children whose command lines touch process environments, cloud metadata, the Kubernetes service-account token, the Docker socket, runner credential files or common credential files, then keeps only hosts that hit two or more classes in the window. This mirrors the stages Wiz describes for the sync-host job. The file and metadata names are this pack's heuristics, not Wiz-published indicators. FP: config management, backup and monitoring agents; baseline before alerting.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: approximate-analyticProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: The result lists a count per class, not the CQL's collected class names.
  • PowerQuery: A command line that mentions a path without any of the listed markers is not counted.
  • PowerQuery assumes: endpoint.os holds the lowercase value linux for Linux agents.
  • PowerQuery assumes: src.process.name is the creator of the new process on a Process Creation event and holds the bare shell name.
  • KQL – Defender XDR: No operating-system filter: a Windows or macOS shell child that mentions a listed marker is counted.
  • KQL – Defender XDR assumes: InitiatingProcessFileName holds the bare shell name on Linux sensors.
  • KQL – Defender XDR assumes: The listed markers appear only on Linux hosts, which stands in for the CQL's platform filter.
// HUNT: multi-class credential sweep by shell children on Linux
// MITRE: T1003.007, T1552.001, T1552.005, T1552.007
// CONF: medium  FP: medium  COST: medium
// REQUIRES: Linux sensor with process creation and command line; best on self-hosted runner hosts (see Q9)
// FALSE POSITIVES: configuration management, backup agents, cloud-init style scripts, monitoring collectors
// TUNING: restrict to runner hosts from Q9 by ComputerName; exclude known automation accounts by UserName; raise classes to 3 on noisy hosts
// LOOKBACK: 7d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| event_platform=Lin
| ParentBaseFileName=/^(bash|sh|dash)$/
| CommandLine=/proc.[^ ]*.environ|169\.254\.169\.254|metadata\.google\.internal|serviceaccount.token|docker\.sock|credentials_rsaparams|runner_credentials|\.git-credentials|terraform\.tfstate|\.bash_history|id_rsa|id_ed25519/i
| case {
    CommandLine=/environ/i | sweep_class := "proc-environ";
    CommandLine=/169\.254\.169\.254|metadata\.google\.internal/i | sweep_class := "cloud-metadata";
    CommandLine=/serviceaccount/i | sweep_class := "k8s-sa-token";
    CommandLine=/docker\.sock/i | sweep_class := "docker-socket";
    CommandLine=/credentials_rsaparams|runner_credentials/i | sweep_class := "runner-creds";
    * | sweep_class := "files-and-keys";
  }
| groupBy([aid, ComputerName], function=[count(as=cmds), count(sweep_class, distinct=true, as=classes), collect(sweep_class)], limit=1000)
| classes >= 2
| sort(classes, order=desc)
Q6 · Container started with the host root filesystem mounted, launched from a shell
CONF mediumFP mediumCOST low

Looks for: docker run from a shell with the host root mounted. Wiz says the sync-host job uses the Docker socket to start a container with the host's root filesystem and repeat its search there. FP: node-inspection and backup tooling mount the root on purpose; baseline first.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: equivalent-with-schema-assumptionsProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: A container started through the Docker API, a compose file or a non-shell parent is not matched.
  • PowerQuery assumes: src.process.name is the creator of the new process on a Process Creation event and holds the bare shell name.
  • PowerQuery assumes: src.process.user is the account that launched the new process.
  • KQL – Defender XDR: A container started through the Docker API, a compose file or a non-shell parent is not matched.
  • KQL – Defender XDR assumes: InitiatingProcessFileName holds the bare shell name on Linux sensors.
// HUNT: docker run with host root mounted, parent is a shell
// MITRE: T1611
// CONF: medium  FP: medium  COST: low
// REQUIRES: Linux sensor, process creation with command line
// FALSE POSITIVES: node diagnostics, backup and forensic containers started by administrators
// TUNING: exclude the administrator accounts and jump hosts that run these tools by UserName or ComputerName
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| FileName=/^docker$/i
| ParentBaseFileName=/^(bash|sh|dash)$/
| CommandLine=/\brun\b/
| CommandLine=/(-v|--volume)[ =]\/:|--mount.*(source|src)=\/(,|\s|$)/
| table([@timestamp, aid, ComputerName, UserName, FileName, CommandLine, ParentBaseFileName])
Q7 · GitHub token stores read by tools other than git or gh (stolen-token source)
CONF lowFP mediumCOST low

Looks for: Command lines that read the places GitHub tokens live on a developer endpoint (git-credentials file, the GitHub CLI hosts file, the Windows credential entry for GitHub) from a process that is not git, gh or a known editor. Wiz names infostealer logs as a token source; this is the closest behavioral signal an endpoint sensor has, and it is weak. FP: backup tools, dotfile managers and credential helpers.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: equivalent-with-schema-assumptionsProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: A token store read through an API call that never appears in a command line is not matched.
  • PowerQuery assumes: tgt.process.name holds the bare image name of the new process.
  • PowerQuery assumes: src.process.user is the account that launched the new process.
  • KQL – Defender XDR: A token store read through an API call that never appears in a command line is not matched.
  • KQL – Defender XDR assumes: FileName holds the bare image name of the created process.
// HUNT: GitHub token store paths in command lines of non-git tools
// MITRE: T1528, T1552.001
// CONF: low  FP: medium  COST: low
// REQUIRES: process creation with command line on developer endpoints
// FALSE POSITIVES: backup agents, dotfile managers, credential helpers, migration scripts
// TUNING: extend the FileName exclusion with your approved credential helpers and editors; alert only when the parent is a script host or an unsigned binary
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| CommandLine=/\.git-credentials|gh.hosts\.yml|git:https:..github\.com/i
| FileName!=/^(git|gh|git-credential[-a-z]*|git-remote-https|code|cursor|ssh|ssh-agent)(\.exe)?$/i
| table([@timestamp, aid, ComputerName, UserName, FileName, CommandLine, ParentBaseFileName])
Q8 · GitHub personal access token pasted into a command line (redacted output)
CONF mediumFP lowCOST low

Looks for: Command lines that contain a GitHub token in its documented prefixed formats. A token in a command line is an exposure on its own, and it is what an operator working from a stolen token on a compromised host would type. The output replaces the token so the result set does not become a second copy of the secret.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: approximate-analytic, equivalent-with-schema-assumptionsProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: The command line is not shown, so the triager must open the event to see the surrounding arguments; the raw event in the console still holds the token and needs the same handling as any exposed credential.
  • PowerQuery assumes: tgt.process.cmdline holds the full command line of the new process.
  • PowerQuery assumes: src.process.user is the account that launched the new process.
  • KQL – Defender XDR: The raw command line, with the token, remains in the underlying event and in any query that projects ProcessCommandLine.
  • KQL – Defender XDR assumes: ProcessCommandLine holds the full command line of the created process.
  • KQL – Defender XDR assumes: replace_regex is available in Advanced Hunting.
// HUNT: GitHub token literal in a process command line (redacted)
// MITRE: T1528, T1552.001
// CONF: medium  FP: low  COST: low
// REQUIRES: process creation with command line
// FALSE POSITIVES: placeholder strings in documentation commands that match the format; test fixtures
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| CommandLine=/\bgh[pousr]_[A-Za-z0-9]{36}\b|github_pat_[A-Za-z0-9_]{22,}/
| replace("(gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,})", with="[REDACTED_GITHUB_TOKEN]", field=CommandLine, as=cmd_redacted)
| table([@timestamp, aid, ComputerName, UserName, FileName, cmd_redacted, ParentBaseFileName])
Q9 · Scope: hosts running the GitHub Actions self-hosted runner
CONF highFP lowCOST low

Looks for: An inventory, not a detection. The sync-host job runs only on repositories that have a registered self-hosted runner, so these are the hosts where Q1 to Q6 matter most and where a ci-sync run would execute attacker steps.

Validation: schema-aligned, static-reviewedAutomated: static-passedEquivalence: equivalent-with-schema-assumptionsProfiles: microsoft-defender-xdr, sentinelone-endpoint-ocsf
Known limitations and deployment notes
  • PowerQuery: A runner started before the lookback window and never restarted is not listed.
  • PowerQuery assumes: tgt.process.name holds the bare image name of the new process.
  • KQL – Defender XDR: A runner started before the lookback window and never restarted is not listed.
  • KQL – Defender XDR assumes: FileName holds the bare image name of the created process.
// HUNT: inventory of hosts running the Actions runner listener or worker
// MITRE: T1059.004
// CONF: high  FP: low  COST: low
// REQUIRES: process creation telemetry on candidate runner hosts
// FALSE POSITIVES: not a detection; every hit is an expected runner host
// LOOKBACK: 30d (set the window with the time picker)
#event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/
| FileName=/^Runner\.(Listener|Worker)(\.exe)?$/i
| groupBy([aid, ComputerName], function=[count(as=starts), min(@timestamp, as=first_seen), max(@timestamp, as=last_seen)], limit=1000)
| sort(last_seen, order=desc)
09

Detection Rules by Platform

Both candidates are detect-only. All indicators come from a single originating source and the rules key on behavior of ordinary tools, so neither is a prevent rule.

CrowdStrike: Custom IOA

IOA nameRule typePatternExclusionsPromotesDeployment path
Gheist helper files or gdmp-pivot in a command lineProcess CreationCommand line matches .gdmp_pat, .gdmp_patg, .gdmp_sweep or gdmp-pivotNone expected; IR workstation group after first reviewPromote Q1Detect on runner hosts; no prevent because the evidence is one source
base32 with 120-column wrap spawned by a shellProcess CreationImage name base32; command line contains -w 120 or --wrap 120; parent image bash, sh or dashDocumented build scripts that wrap at 120 after baselinePromote Q3Detect for 14 days, then decide; never prevent without a second source

Q5 and Q6 stay as scheduled hunts until a 14-day baseline shows the automation and diagnostic noise they will produce.

SentinelOne: custom detection rules

RuleSource queryRun asNotes
Gheist helper files or gdmp-pivot in a command lineQ1, PowerQuery tabScheduled custom detection ruleBind entities so the alert names the host instead of "Unknown Device". This pack's own text mentions a baseline or pilot; follow it as written
base32 with 120-column wrap spawned by a shellQ3, PowerQuery tabScheduled custom detection ruleBind entities so the alert names the host instead of "Unknown Device". This pack's own text mentions a baseline or pilot; follow it as written

Create the rule with POST /web/api/v2.1/cloud-detection/rules, queryType: "scheduled" and queryLang: "2.0", with the query in data.scheduledParams.query. Use 2.0 operator spellings: a rule written with the 1.0 token ContainsCIS was accepted and never fired on a tenant. The API does not warn, so lint the body before deploying.

Microsoft Defender: custom detection rules

RuleSource queryRun asNotes
Gheist helper files or gdmp-pivot in a command lineQ1, KQL – Defender XDR tabCustom detection rule, alert only, every hourThe query returns Timestamp, ReportId and DeviceId, which Microsoft recommends for Defender for Endpoint tables so alerts carry the device group scope and the process tree. Remove the Timestamp filter and the final sort and take before saving the rule: the service applies the rule's lookback itself. This pack's own text mentions a baseline or pilot; follow it as written
base32 with 120-column wrap spawned by a shellQ3, KQL – Defender XDR tabCustom detection rule, alert only, every hourThe query returns Timestamp, ReportId and DeviceId, which Microsoft recommends for Defender for Endpoint tables so alerts carry the device group scope and the process tree. Remove the Timestamp filter and the final sort and take before saving the rule: the service applies the rule's lookback itself. This pack's own text mentions a baseline or pilot; follow it as written

Q2, Q4, Q5, Q6, Q7, Q8 and Q9 stay a hunt in this pack, not a rule. A rule can generate only 150 alerts per run. Continuous (NRT) frequency needs a single table and no comment lines, so delete the generated comment header from the query first.

10

Machine-Readable IOC Appendix

The Falcon CSV below has no importable row. Wiz published no domain, IP address or endpoint file hash, and Falcon IOC Management cannot hold user agents, branch names or filenames. The only row is a labelled placeholder. The git blob ids are shown in their own block with their real meaning. Nothing in this pack ships as prevent.

Falcon IOC Management CSVbulk import
type,value,action,severity,expiration,description,tags
sha256,REPLACE_WITH_SHA256_OF_PUBLISHED_RUNNER_HELPER_FILE,detect,medium,2027-04-26,Gheist self-hosted job helper file (Wiz has published no endpoint file hash),campaign:Gheist
# NOT imported: Falcon IOC Management has no type for user agents, branch names, commit messages or filenames. Use Q1 to Q4 and the native hunts.
# NOT imported: git blob SHA-1 ids below are object ids inside a repository, NOT endpoint file hashes. Do not import them as sha1 IOCs.
#   f3da2b8b357dcda02fd455d314be2a95007516a6 (plain workflow)
#   d09535857a741bbb976bdc084a9a021713133291 (timeout version)
#   1dc5af9cce950aad1eb1546ecce8565b117540c4 (separator version)
#   c665b778b2d822453293003e7ca0bbb2cb08e39d (self-hosted job version)
# No domain, IP address or endpoint file hash has been published for this campaign. Nothing here is a prevent row.
SentinelOne IOC importnot included

This pack does not ship a SentinelOne import block: the import format could not be verified against SentinelOne's own documentation. Export a sample from your console and the block can be added with confidence.

Behavioral Signaturessearch strings
# Behavioral and string signatures (hunt strings, not IOC Management values)
User agent (GitHub audit log / API): gheist
Branch regex: ^ci-sync-[0-9a-z]{6}$   (Tool A hexadecimal suffix, Tool B base36 suffix)
Commit: message "ci: sync config", unsigned, author = token owner, one added file
File: .github/workflows/sync-check.yml   (workflow name: sync-check; second job in self-hosted version: sync-host)
Workflow string: toJSON(secrets) }}' | base32 -w 120
Log header: ===== gdmp-pivot:   (followed by the stage name)
Separator output (from about 2026-10-19): LAFA====   (the base32 of a single X)
Runner helper files: .gdmp_pat  .gdmp_patg  .gdmp_sweep
Process lineage worth watching on runners: Runner.Worker -> bash -> base32 / find / grep / docker run
Git Blob SHA-1 Ids (not file hashes)repository hunt
# Git blob SHA-1 ids of the published workflow versions (object ids in a repository, NOT endpoint file hashes)
f3da2b8b357dcda02fd455d314be2a95007516a6   plain workflow
d09535857a741bbb976bdc084a9a021713133291   with timeout-minutes
1dc5af9cce950aad1eb1546ecce8565b117540c4   with base32 separator
c665b778b2d822453293003e7ca0bbb2cb08e39d   with self-hosted sync-host job
# Compute for a file you hold:  git hash-object .github/workflows/sync-check.yml
# Read from a branch without checkout:  git rev-parse origin/ci-sync-xxxxxx:.github/workflows/sync-check.yml
# Environment-matrix variants list the repository's own environments and have no fixed id.
Campaign Scope and Variantscontext
Campaign name: Gheist (Wiz). Named after the gheist user agent. No actor or malware family name has been published.
Tracked since: 2026-09-15. Variants: Tool A (hex suffix, plain payload), Tool B (base36 suffix, timeout and environment matrix from 2026-09-28,
  separator from 2026-10-19, self-hosted job from about 2026-10-19).
Scale (Wiz, as of 2026-10-24): 13,500+ planted branches, 850+ accounts, about two thirds of accounts no longer on GitHub.
Exposed: private repositories and GitHub Enterprise Server (no approval hold). Public github.com repositories: runs held, payload did not run.
Exfiltration: GitHub run log through the API, then run deleted. No attacker domain or IP.
Related, NOT this campaign's indicators: GhostAction, Megalodon, PolinRider, Trivy compromise (token source). Not shipped here.
Rotation and Response Checklistrotate and verify
# Rotation scope for any repository a stolen token could write to (Wiz: deleting the run or branch does not undo exposure)
1. Repository secrets, organisation secrets visible to the repository, and every deployment environment's secrets
2. Cloud keys and roles those secrets unlock (AWS, GCP, Azure); review Secrets Manager reads since 2026-09-15
3. Revoke the token(s) that pushed the commits; check the owner's device for an infostealer BEFORE issuing a new token
4. Self-hosted runner that ran a ci-sync branch: treat the host and its instance role as compromised; rebuild from a clean image
5. Registry, package and signing credentials stored as secrets
# Do not approve held sync-check runs. Do not run code from ci-sync branches.
11

Detection Validation Gates

GateCheckPass condition
Telemetry readyProcess creation with command line and parent (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents) on every runner host from Q9; file write for scripts and ELF files (CrowdStrike: NewScriptWritten, ElfFileWritten. SentinelOne: File Creation. Defender: DeviceFileEvents); GitHub audit-log streaming into your SIEM.A sample process event with a populated ParentBaseFileName is searchable for each runner host in the last hour. Confirm whether Q2's file events exist on your sensor version.
Benign baselineRun Q3 to Q8 over 14 days on runner hosts and developer endpoints before alerting.Fewer than 10 hits per week per query, each explained by a named tool or account. Q5 and Q6 will need exclusions.
Positive testsIn a lab only: Q1 run echo gdmp-pivot-test through a shell; Q3 run echo test | base32 -w 120 from bash; Q4 run git ls-remote origin refs/heads/ci-sync-a1b2c3 against a test repository; Q8 run a command containing a clearly fake token string of the right shape.Each test appears in its query within minutes. Q2 may not fire for plain data files; record that result rather than assuming coverage.
PromotionPromote Q1 and Q3 to detect-only IOAs after the baseline.No prevent rule is created. A second independent source and a clean 14-day baseline are required first.
12

Hardening: Tiered & Deployable

Immediate (this week)
  • T1. Require reviewers and protected-branch deployment on every environment that holds production secrets. A job cannot read an environment's secrets until a required reviewer approves it, so a planted environment-matrix job stalls instead of dumping. Framework: ATT&CK T1528 and M1026, M1018; GitHub environments documentation; OWASP Top 10 CI/CD Security Risks (CICD-SEC-5, CICD-SEC-6). Verify: Playbook P1.
  • T2. Scope cloud OIDC trust policies to a protected branch or an environment, not any ref. A throwaway branch then cannot assume your deploy role. Framework: T1078.004 and M1026, M1018; GitHub OIDC hardening documentation; cloud provider IAM guidance. Verify: Playbook P2 and N3.
  • T3. Replace classic personal access tokens with GitHub Apps or short-lived fine-grained tokens, and keep the Workflows permission off tokens that do not edit workflows. The planted file needs a token that can push workflow files. Framework: T1528, T1078.004 and M1018, M1026, M1032; GitHub token guidance. Verify: inventory classic tokens in the organisation settings.
  • T4. Run self-hosted runners as ephemeral hosts with no Docker socket and no open instance-metadata access. Limits what a sweep can reach and removes the host-root container route. Framework: T1552.005, T1611, T1003.007 and M1035, M1048, M1026; Wiz recommendation; AWS IMDSv2 guidance; CIS Docker Benchmark. Verify: Playbook P3 and Q6.
Near term (1 to 4 weeks, pilot first)
  • T5. Stream the GitHub audit log to your SIEM and alert on the gheist user agent and on runs deleted within a minute of finishing. This is the only place most of the campaign is visible. Framework: M1047; ATT&CK T1070. Verify: N2 returns events.
  • T6. Add a branch-creation ruleset for the ci-sync-* pattern as a tripwire. It blocks the observed branch name only, so treat it as cheap friction and alerting, not a control. Framework: best-practice, no formal benchmark. Verify: Playbook P4.
  • T7. Protect developer endpoints against infostealers and shorten token lifetime. Stolen tokens come from infostealer logs. Framework: M1049 and M1017; endpoint protection baselines. Verify: Q7 and Q8 baseline.
Strategic (1 to 3 months)
  • T8. Move deploy credentials out of repository secrets into OIDC federation bound to environments. Removes long-lived secrets that a workflow can print. Framework: M1027, M1041; OWASP CICD-SEC-6. Verify: secrets inventory shrinks.
  • T9. Adopt just-in-time ephemeral runners (for example an autoscaled runner controller) for every repository that needs self-hosted capacity. Framework: M1048; CIS Software Supply Chain Security Guide. Verify: no long-lived runner hosts in Q9.
  • T10. Ask GitHub about extending the approval hold to private repositories and Enterprise Server. GitHub says the hold currently applies to public github.com repositories only. [#3] Framework: vendor roadmap, no benchmark.
13

Deployable Playbooks

P1 · Require reviewers on a production environment (GitHub REST, repository admin)

# 1. Save the current settings first (the PUT below replaces them)
gh api repos/OWNER/REPO/environments/production > environment-production.before.json
# 2. Require a reviewer team and restrict deployments to protected branches
gh api -X PUT repos/OWNER/REPO/environments/production --input - <<'JSON'
{
  "reviewers": [ { "type": "Team", "id": REPLACE_WITH_TEAM_ID } ],
  "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
}
JSON
# Verify
gh api repos/OWNER/REPO/environments/production -q '.protection_rules'
Prerequisites: repository admin; environments available on your plan; at least one reviewer team that can approve promptly.
Reboot required: no
Rollback: re-apply the saved environment-production.before.json settings with the same PUT call (edit it to the request-body shape first).

P2 · Scope an AWS OIDC trust policy to an environment

# Trust policy condition (replace the placeholders; test on a non-production role first)
"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:REPLACE_WITH_ORG/REPLACE_WITH_REPO:environment:production"
  }
}
# Verify: list a role's current trust policy and read the sub condition
aws iam get-role --role-name REPLACE_WITH_ROLE --query 'Role.AssumeRolePolicyDocument'
Prerequisites: the deploy job names the environment so the subject carries it; a saved copy of the current trust policy.
Reboot required: no
Rollback: aws iam update-assume-role-policy with the saved previous document. Deploys from branches will fail until the job uses the environment.

P3 · Harden a self-hosted runner host (Linux)

# 1. Record current state
id runner; aws ec2 describe-instances --instance-ids REPLACE_WITH_INSTANCE_ID --query 'Reservations[].Instances[].MetadataOptions'
# 2. Remove the runner account from the docker group (no Docker socket for jobs)
sudo gpasswd -d runner docker
# 3. Require IMDSv2 and a hop limit of 1 so containers and job steps cannot reach role credentials easily
aws ec2 modify-instance-metadata-options --instance-id REPLACE_WITH_INSTANCE_ID --http-tokens required --http-put-response-hop-limit 1
# 4. Register new runners as ephemeral so each job gets a clean host
./config.sh --url https://github.com/REPLACE_WITH_ORG --token REPLACE_WITH_REGISTRATION_TOKEN --ephemeral
Prerequisites: workflows on this runner do not need docker; no job needs the instance role through a container; registration token from the GitHub UI.
Reboot required: no (restart the runner service after the group change)
Rollback: sudo usermod -aG docker runner; aws ec2 modify-instance-metadata-options with the values recorded in step 1.

P4 · Tripwire ruleset for ci-sync-* branch creation (organisation owner)

gh api -X POST orgs/REPLACE_WITH_ORG/rulesets --input - <<'JSON'
{
  "name": "block-ci-sync-branches",
  "target": "branch",
  "enforcement": "active",
  "conditions": {
    "ref_name": { "include": ["refs/heads/ci-sync-*"], "exclude": [] },
    "repository_name": { "include": ["~ALL"], "exclude": [] }
  },
  "rules": [ { "type": "creation" } ]
}
JSON
# Confirm the request shape against GitHub's current REST reference for your plan before running.
Prerequisites: organisation owner; rulesets available on your plan; no team legitimately uses ci-sync-* branch names.
Reboot required: no
Rollback: gh api -X DELETE orgs/REPLACE_WITH_ORG/rulesets/REPLACE_WITH_RULESET_ID
Limit: this blocks the observed branch name only. An operator can change the name.

P5 · Read-only exposure triage (no changes)

# Runs GitHub is holding (do NOT approve them)
gh run list --repo OWNER/REPO --status action_required --json databaseId,headBranch,workflowName,createdAt
# Secrets a repository could read (names only, no values)
gh secret list --repo OWNER/REPO
for e in $(gh api repos/OWNER/REPO/environments -q '.environments[].name'); do echo "== $e"; gh secret list --repo OWNER/REPO --env "$e"; done
gh secret list --org ORG
Prerequisites: GitHub CLI authenticated with read access to secrets metadata.
Reboot required: no
Rollback: none required, every command is read-only.

P6 · Secret rotation checklist

Work from a clean machine, not from the owner's possibly infected device.
1. Rotate each secret at its source system first, then update the GitHub secret. Rotating only the GitHub copy changes nothing.
2. Revoke the stolen token(s). Check the owner's device for an infostealer before issuing a new token.
3. Review cloud audit logs since 2026-09-15 for use of the old credentials, including Secrets Manager reads.
4. Delete ci-sync branches only after recording commit ids, authors and times for the incident record.
Prerequisites: admin rights at each source system.
Reboot required: no
Rollback: not applicable; a rotated or revoked secret should stay revoked.
14

Containment Runbook

PhaseActionsOwnerEvidence
IsolateRevoke the token(s) that pushed the ci-sync commits. Pause Actions or disable the affected repositories' workflows while you scope. Network-contain any self-hosted runner that ran a ci-sync branch or hit Q1, Q2, Q3 or Q5 (Falcon network containment). Do not approve held runs and do not run code from ci-sync branches.SOC / DevOps / GitHub org ownerToken revocation record, containment timestamp, list of affected repositories
ScopeRun N1, N2 and N3 across the organisation for 2026-09-15 onward. List every repository, organisation and environment secret each affected repository could read (P5). Run Q9 then Q1 to Q6 on runner hosts. Check the token owners' devices for infostealers.SOC / IRBranch list with commit ids, audit-log extract, secret inventory per repository
EradicateRotate all exposed secrets from a clean machine (P6). Rebuild any runner that ran a ci-sync branch from a clean image and rotate its instance-role credentials. Clean infostealer-affected devices before issuing new tokens.IR / secret ownersRotation ticket per secret class, rebuild record
RecoverRe-enable workflows after secrets are rotated. Apply hardening T1 to T4. Add alerting for the gheist user agent and fast run deletion (T5).DevOps / Security engineeringEnvironment protection config, OIDC policy diff, audit-log alert test
EvidencePreserve audit-log exports, branch and commit ids, runner _diag logs and process telemetry before deleting branches or rebuilding hosts.IRHashes of exported files, chain-of-custody note
15

Detection Coverage Map

TechniqueBehaviorCQLIOACrowdStrike coverageSentinelOneDefender XDR
T1078.004 / T1528Stolen tokens used by the tool from attacker infrastructureGAPGAPGAP
T1213.003Private repositories cloned before the plantGAPGAPGAP
T1070Run deleted seconds after the log is downloaded; gheist user agentGAPGAPGAP
T1567Secrets exfiltrated through the GitHub run log and APIGAPGAPGAP
T1059.004 / T1027Workflow step runs base32 on the environment (self-hosted runners only)Q3Q3PartialPartial: same limit as CrowdStrikePartial: same limit as CrowdStrike
T1552.001Sweep helper files and gdmp-pivot headerQ1, Q2Q1PartialPartial: Q2 approximatePartial: Q2 approximate
T1003.007 / T1552.005 / T1552.007Environment, metadata and service-account token sweepQ5PartialPartial: Q5 approximatePartial: Q5 approximate
T1611Container with host root mountedQ6PartialPartial: same limit as CrowdStrikePartial: same limit as CrowdStrike
T1078.004git or gh naming a ci-sync branch on a covered hostQ4PartialPartial: same limit as CrowdStrikePartial: same limit as CrowdStrike
T1528 / T1552.001Token stores read or token pasted into a command on developer endpointsQ7, Q8PartialPartial: Q8 approximatePartial: same limit as CrowdStrike
T1555.006AWS Secrets Manager enumeration with cloud credentialsGAPGAPGAP

Honesty note. The core of this campaign is outside endpoint telemetry: branch creation, the workflow run on a GitHub-hosted runner, the log download, the run deletion, the gheist user agent and the cloud follow-on are visible only in GitHub and cloud audit logs (Section 7). Falcon coverage is Partial at best and applies only to self-hosted runner hosts and developer endpoints that have a sensor. A quiet Falcon result does not mean no secrets were taken. Validation gates are in Section 11.

16

Hunt Summary Ticket

TITLE:        Gheist stolen-token GitHub Actions secret dump (Wiz, tracked since 2026-09-15)
SEVERITY:     High. Secret theft from private repositories and Enterprise Server; campaign active as of 2026-10-24
SCOPE:        Every repository reachable by a stolen GitHub token; self-hosted runner hosts; cloud roles and secrets
              reachable from those repositories
HYPOTHESIS:   An operator uses stolen tokens to push a ci-sync-xxxxxx branch with one workflow that prints
              base32-encoded secrets into the Actions log, downloads the log through the API and deletes the run.
              A newer variant sweeps self-hosted runner hosts.
QUERIES:      9 CQL queries (Q1-Q9). Q1, Q2, Q3 high confidence on runner hosts; Q1 and Q3 promotable to
              detect-only IOAs. N1-N6 cover the GitHub and cloud side.
DO FIRST:     1) Run N1, N2, N3 across the organisation. 2) Run Q9, then Q1-Q6 on runner hosts. 3) If any
              hit: revoke the pushing token, rotate every reachable secret (P6), rebuild affected runners.
FINDINGS:     [fill in after the hunt]
GAPS:         Branch creation, workflow runs on hosted runners, log download, run deletion and the cloud
              follow-on are outside endpoint telemetry. Helper file writes may not raise a file event.
ACTIONS:      Rotate secrets; revoke tokens; environment reviewers; OIDC scoped to environments; ephemeral
              runners without Docker socket or open metadata; stream the GitHub audit log.
OWNER:        REPLACE_WITH_OWNER
VERSION:      v0.1 (Draft) 2026-10-26
17

Changelog

v0.12026-10-26Initial draft. 9 CQL queries, 2 detect-only IOA candidates, 6 native hunts, tiered hardening with 6 playbooks. 6 sources fetched and saved as snapshots; no indicator from a related campaign shipped. SentinelOne PowerQuery and Microsoft Defender KQL tabs added to Q1-Q9: SIEM Query Rosetta release 0.1.0 (public-beta; static-reviewed translations).
18

References

TierSourceUsed forAccess date
T1Wiz Research: Gheist, Attempted Scaled Abuse of Stolen GitHub Tokens StumblesPrimary source for the whole pack: chain, variants, indicators, hunt and response guidance.2026-10-26
T2The Hacker News: Gheist Uses Stolen GitHub Tokens to Steal Private Repository Secrets via Actions LogsSecond write-up, GitHub quotes on the hold and on write access.2026-10-26
T1GitHub changelog: Actions holds potentially malicious workflows for approvalScope of the approval hold.2026-10-26
T3Safeguard: Gheist, Stolen GitHub Tokens and the sync-check Secret DumpCross-check of indicators and the OIDC subject format.2026-10-26
T3SecurityOnline: Gheist Campaign Abuses Stolen GitHub Tokens Across 850 AccountsCorroborating summary.2026-10-26
T3Mallory: Gheist Abuses Stolen GitHub Tokens to Steal Actions Secrets and Target RunnersTimeline cross-check.2026-10-26

Generated 2026-10-26 by HuntPack. Defensive content only.