Gheist: Stolen-Token GitHub Actions Secret Dump
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.
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.
| # | Tier | Source | Key finding | Carry forward |
|---|---|---|---|---|
| 1 | T1 | Wiz 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 |
| 2 | T2 | The 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 |
| 3 | T1 | GitHub 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 |
| 4 | T3 | Safeguard 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 |
| 5 | T3 | SecurityOnline (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 |
| 6 | T3 | Mallory 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.
Hunt Brief & Attack Chain
Seven hypotheses, ordered by fidelity. Telemetry is named in plain terms first, then by platform.
- 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).
- H2 (high): a shell on a runner or developer host encoded the environment with base32 at 120 columns (Q3). T1027. FP: rare scripts.
- 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.
- H4 (medium): a host fetched or pushed a ci-sync branch (Q4). T1078.004, T1213.003. FP: responders.
- 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.
- 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.
- 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
| Step | What happens | Telemetry that could see it | Hunt angle |
|---|---|---|---|
| 1 | Tokens 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 |
| 2 | Tool 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 |
| 3 | Creates 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 it | N1, N5, Q4 |
| 4 | Push 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 |
| 5 | Tool 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 block | N2 |
| 6 | Self-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 |
| 7 | Follow-on: cloud credentials from the same organisation used to enumerate AWS Secrets Manager [#1] [#2] | Cloud provider audit log (outside endpoint telemetry) | N3 |
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.
| Type | Value | Confidence | Action | Context |
|---|---|---|---|---|
| User agent | gheist | high | hunt | Sent by the tool on GitHub API requests; GitHub audit log or streaming only. [#1] [#2] |
| Branch pattern | ^ci-sync-[0-9a-z]{6}$ | high | hunt | Tool A suffix hexadecimal, Tool B base36. [#1] [#2] |
| Commit message | ci: sync config | high | hunt | Commit 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.yml | high | hunt | Workflow name sync-check. Second job in the self-hosted version is sync-host. [#1] [#2] |
| Payload string | toJSON(secrets) }}' | base32 -w 120 | high | hunt | In the workflow file content. [#1] [#2] |
| Log header | ===== gdmp-pivot: | high | detect | Written by the self-hosted job before each stage name. [#1] [#2] |
| Output marker | LAFA==== | medium | hunt | What 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_pat | high | detect | Self-hosted job helper file. Content not published. [#1] [#2] |
| Filename | .gdmp_patg | high | detect | Self-hosted job helper file. [#1] [#2] |
| Filename | .gdmp_sweep | high | detect | Self-hosted job helper file. [#1] [#2] |
| Git blob SHA-1 | f3da2b8b357dcda02fd455d314be2a95007516a6 | high | hunt | Plain workflow. A git object id, not a file hash. [#1] [#2] |
| Git blob SHA-1 | d09535857a741bbb976bdc084a9a021713133291 | high | hunt | Version with timeout-minutes. [#1] [#2] |
| Git blob SHA-1 | 1dc5af9cce950aad1eb1546ecce8565b117540c4 | high | hunt | Version with the base32 separator. [#1] [#2] |
| Git blob SHA-1 | c665b778b2d822453293003e7ca0bbb2cb08e39d | high | hunt | Version with the self-hosted job. Environment-matrix variants differ per repository and have no fixed id. [#1] [#2] |
Affected Surface & Telemetry Matrix
| Surface | Telemetry | Priority | Gap 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) | High | Plain-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 tokens | Process creation with command line (as above) | Medium | Infostealer file reads are usually in-process and not logged; Q7 and Q8 catch only command-line handling |
| GitHub-hosted runners | None. No customer sensor runs there | High | Outside 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 streaming | High | Outside 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 CloudTrail | High | Outside endpoint telemetry. Only helps when OIDC is used and the log is collected |
ATT&CK Mapping
| Tactic | Technique | Observed behavior | Query or control |
|---|---|---|---|
| Initial Access | T1078.004 Valid Accounts: Cloud Accounts | Stolen GitHub tokens of users and automation accounts are used directly | N2; Hardening T3 |
| Credential Access | T1528 Steal Application Access Token | Tokens taken from infostealer logs and earlier breaches | Q7, Q8; Hardening T3 |
| Collection | T1213.003 Data from Information Repositories: Code Repositories | Private repositories cloned before the workflow is planted | N2; Hardening T3 |
| Execution | T1059.004 Unix Shell | Workflow steps and the self-hosted job run shell commands | Q1, Q3, Q9 |
| Defense Evasion | T1027 Obfuscated Files or Information | base32 encoding so log masking does not match | Q3 |
| Defense Evasion | T1070 Indicator Removal | Workflow run deleted seconds after the log is downloaded | N2 |
| Credential Access | T1552.001 Credentials in Files | Self-hosted job searches for token formats, private keys, .env files, Terraform state, shell history | Q1, Q2, Q5 |
| Credential Access | T1003.007 Proc Filesystem | Environment of every process read from /proc | Q5 |
| Credential Access | T1552.005 Cloud Instance Metadata API | AWS instance-role credentials and GCP or Azure metadata requested | Q5; Hardening T4 |
| Credential Access | T1552.007 Container API | Kubernetes service-account token read | Q5 |
| Privilege Escalation | T1611 Escape to Host | Container started with the host root filesystem mounted via the Docker socket | Q6; Hardening T4 |
| Exfiltration | T1567 Exfiltration Over Web Service | Secrets leave through the GitHub run log and API, no attacker server | N2 (outside endpoint telemetry) |
| Discovery | T1555.006 Credentials from Password Stores: Cloud Secrets Management Stores | AWS Secrets Manager enumerated with cloud credentials from the same organisation | N3 (outside endpoint telemetry) |
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 |"
doneAn 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.
Hunt Queries: CrowdStrike CQL, SentinelOne PowerQuery and Defender XDR KQL
Use your tenant's own address. SentinelOne does not document a link that pre-fills a query, so each "Copy & open SentinelOne" button copies the query and opens this console: paste it into PowerQuery.
Pick your portal. Microsoft documents a Share link in advanced hunting but not its URL format, so each "Copy & open Defender" button copies the query and opens Advanced hunting: paste it into the editor.
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.
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.
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])
2 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typetgt.process.cmdline: New process command line
Recommended fields
endpoint.name: Host nametgt.process.name: New process namesrc.process.name: Creator process name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.cmdline = * | limit 5 | columns event.time, event.type, tgt.process.cmdline
// HUNT: Gheist self-hosted job helper files and log header in command lines // SCHEMA PROFILE: sentinelone-endpoint-ocsf // MITRE: T1552.001, T1059.004 // CONF: high | FP: low // FALSE POSITIVES: an analyst running find or grep for these strings during triage // EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: the helper file names or the pivot marker in the new process's command line. matches is case-insensitive, which reproduces the CQL /i intent. // REQUIRES: SentinelOne process-creation events with command line on self-hosted runner hosts // TUNING: None expected; exclude the triage workstation group by endpoint.name if analysts grep for these strings. // LOOKBACK: 30d (set the window with the SentinelOne time picker) // CONFIGURATION: No lookup objects or data tables required. // KNOWN GAPS: A command line the agent did not capture, or a process it did not see start, is not matched. // VALIDATION LEVEL: static-reviewed event.type = 'Process Creation' tgt.process.cmdline matches '\\.gdmp_(pat|patg|sweep)\\b|gdmp-pivot' | columns event.time, agent.uuid, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline | sort -event.time | limit 200
Automated validation: static-passed
2 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeProcessCommandLine: Created command line
Recommended fields
InitiatingProcessFileName: Initiating process
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, ProcessCommandLine | take 5
// HUNT: Gheist self-hosted job helper files and log header in command lines // SCHEMA PROFILE: microsoft-defender-xdr // MITRE: T1552.001, T1059.004 // CONF: high | FP: low // FALSE POSITIVES: an analyst running find or grep for these strings during triage // EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate on the created process's command line. The (?i) prefix reproduces the CQL /i intent. // REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) on self-hosted runner hosts // TUNING: None expected; exclude the triage workstation group by DeviceName if analysts grep for these strings. // LOOKBACK: 30d (Advanced Hunting retention is 30 days) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: A process the sensor did not observe starting is not captured. // VALIDATION LEVEL: schema-aligned // AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0 DeviceProcessEvents | where Timestamp >= ago(30d) | where ProcessCommandLine matches regex @"(?i)\.gdmp_(pat|patg|sweep)\b|gdmp-pivot" | project Timestamp, ReportId, DeviceId, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName | sort by Timestamp desc | take 200
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.
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])
2 required fields · View requirements
Required telemetry
- File Creation events
Required fields
event.type: Event typetgt.file.path: Created file path
Recommended fields
endpoint.name: Host namesrc.process.name: Writing process
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'File Creation' tgt.file.path = * | limit 5 | columns event.time, event.type, tgt.file.path
// HUNT: Gheist helper file written on a host // SCHEMA PROFILE: sentinelone-endpoint-ocsf // MITRE: T1552.001 // CONF: high | FP: low // FALSE POSITIVES: none expected; the names are specific to the Wiz-reported job // EQUIVALENCE: approximate-analytic - The CQL reads script and ELF write events that depend on sensor file classification; this matches any file creation whose path ends in a helper name. It can match more file types than the CQL and does not see a write to an existing file. // REQUIRES: SentinelOne file-creation events on candidate runner hosts // TUNING: None expected; the helper names are specific to the reported job. // LOOKBACK: 30d (set the window with the SentinelOne time picker) // CONFIGURATION: No lookup objects or data tables required. // KNOWN GAPS: A path with backslash separators (Windows) is not matched. A helper written over an existing file is a modification event and is not matched. // VALIDATION LEVEL: static-reviewed event.type = 'File Creation' tgt.file.path matches '[/][.]gdmp_(pat|patg|sweep)$' | columns event.time, agent.uuid, endpoint.name, src.process.name, src.process.image.path, tgt.file.path | sort -event.time | limit 200
Automated validation: static-passed
3 required fields · View requirements
Required telemetry
- DeviceFileEvents
Required fields
Timestamp: Event timeActionType: ActionFileName: File name
Recommended fields
InitiatingProcessFileName: Writing processFolderPath: Folder
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceFileEvents | where Timestamp > ago(1d) | project Timestamp, ActionType, FileName | take 5
// HUNT: Gheist helper file written on a host // SCHEMA PROFILE: microsoft-defender-xdr // MITRE: T1552.001 // CONF: high | FP: low // FALSE POSITIVES: none expected; the names are specific to the Wiz-reported job // EQUIVALENCE: approximate-analytic - The CQL reads script and ELF write events that depend on sensor file classification; this matches any FileCreated event whose file name is a helper name, so it can match more file types than the CQL. // REQUIRES: Defender for Endpoint file telemetry (DeviceFileEvents) on candidate runner hosts // TUNING: None expected; the helper names are specific to the reported job. // LOOKBACK: 30d (Advanced Hunting retention is 30 days) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: A helper written over an existing file is recorded as a modification and is not matched. // VALIDATION LEVEL: schema-aligned // AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0 DeviceFileEvents | where Timestamp >= ago(30d) | where ActionType == "FileCreated" | where FileName matches regex @"(?i)^\.gdmp_(pat|patg|sweep)$" | project Timestamp, ReportId, DeviceId, DeviceName, FileName, FolderPath, InitiatingProcessFileName | sort by Timestamp desc | take 200
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.
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])
4 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typetgt.process.name: New process namesrc.process.name: Creator process nametgt.process.cmdline: New process command line
Recommended fields
endpoint.name: Host name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns event.time, event.type, tgt.process.name, src.process.name, tgt.process.cmdline
// HUNT: base32 with 120-column wrap spawned by a shell
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1027, T1552.001
// CONF: high | FP: low
// FALSE POSITIVES: a developer or script that happens to wrap base32 output at 120 columns
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: base32 started by bash, sh or dash with a 120-column wrap flag. The new process is tgt.process.*; its creator is src.process.*. The command-line regex is case-sensitive, as in the CQL.
// REQUIRES: SentinelOne Linux or macOS process-creation events with command line
// TUNING: Exclude developer hosts that legitimately wrap base32 output at 120 columns, by endpoint.name.
// LOOKBACK: 30d (set the window with the SentinelOne time picker)
// CONFIGURATION: No lookup objects or data tables required.
// KNOWN GAPS: A base32 run whose creator is not bash, sh or dash is not matched.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' tgt.process.name in:anycase ('base32') src.process.name in ('bash', 'sh', 'dash') tgt.process.cmdline matches:matchcase '-w\\s*120|--wrap[= ]+120'
| columns event.time, agent.uuid, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline
| sort -event.time
| limit 200Automated validation: static-passed
4 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeFileName: Created process nameInitiatingProcessFileName: Initiating processProcessCommandLine: Created command line
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, FileName, InitiatingProcessFileName, ProcessCommandLine | take 5
// HUNT: base32 with 120-column wrap spawned by a shell
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1027, T1552.001
// CONF: high | FP: low
// FALSE POSITIVES: a developer or script that happens to wrap base32 output at 120 columns
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: base32 created by bash, sh or dash with a 120-column wrap flag. The creator is InitiatingProcessFileName. The command-line regex is case-sensitive, as in the CQL.
// REQUIRES: Defender for Endpoint Linux or macOS process-creation telemetry (DeviceProcessEvents)
// TUNING: Exclude developer hosts that legitimately wrap base32 output at 120 columns, by DeviceName.
// LOOKBACK: 30d (Advanced Hunting retention is 30 days)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A base32 run whose creator is not bash, sh or dash is not matched.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(30d)
| where FileName =~ "base32"
| where InitiatingProcessFileName in ("bash", "sh", "dash")
| where ProcessCommandLine matches regex @"-w\s*120|--wrap[= ]+120"
| project Timestamp, ReportId, DeviceId, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName
| sort by Timestamp desc
| take 200Looks 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.
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])
3 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typetgt.process.name: New process nametgt.process.cmdline: New process command line
Recommended fields
endpoint.name: Host namesrc.process.name: Creator process name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns event.time, event.type, tgt.process.name, tgt.process.cmdline
// HUNT: git or gh command lines naming a ci-sync branch or the planted commit message
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1078.004, T1213.003
// CONF: medium | FP: medium
// FALSE POSITIVES: incident responders fetching ci-sync branches as evidence; a team that really uses the message "ci: sync config"
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: git or gh by name, with a ci-sync branch name or the planted commit message in the command line. matches is case-insensitive, which reproduces the CQL /i intent.
// REQUIRES: SentinelOne process-creation events with command line on developer endpoints and runner hosts
// TUNING: Remove the commit-message alternative if it is noisy; exclude the incident-response workstation group by endpoint.name after the first review.
// LOOKBACK: 30d (set the window with the SentinelOne time picker)
// CONFIGURATION: No lookup objects or data tables required.
// KNOWN GAPS: A git operation run through a wrapper whose image name is not git or gh is not matched.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' tgt.process.name matches '^(git|gh)([.]exe)?$' tgt.process.cmdline matches '\\bci-sync-[0-9a-z]{6}\\b|ci: sync config'
| columns event.time, agent.uuid, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline
| sort -event.time
| limit 200Automated validation: static-passed
3 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeFileName: Created process nameProcessCommandLine: Created command line
Recommended fields
InitiatingProcessFileName: Initiating process
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, FileName, ProcessCommandLine | take 5
// HUNT: git or gh command lines naming a ci-sync branch or the planted commit message
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1078.004, T1213.003
// CONF: medium | FP: medium
// FALSE POSITIVES: incident responders fetching ci-sync branches as evidence; a team that really uses the message "ci: sync config"
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: git or gh by name, with a ci-sync branch name or the planted commit message in the command line. The (?i) prefix reproduces the CQL /i intent.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) on developer endpoints and runner hosts
// TUNING: Remove the commit-message alternative if it is noisy; exclude the incident-response workstation group by DeviceName after the first review.
// LOOKBACK: 30d (Advanced Hunting retention is 30 days)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A git operation run through a wrapper whose image name is not git or gh is not matched.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(30d)
| where FileName matches regex @"(?i)^(git|gh)(\.exe)?$"
| where ProcessCommandLine matches regex @"(?i)\bci-sync-[0-9a-z]{6}\b|ci: sync config"
| project Timestamp, ReportId, DeviceId, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName
| sort by Timestamp desc
| take 200Looks 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.
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)
5 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typeendpoint.os: Operating systemsrc.process.name: Creator process nametgt.process.cmdline: New process command lineagent.uuid: Host identifier
Recommended fields
endpoint.name: Host name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' endpoint.os = 'linux' | limit 5 | columns event.time, event.type, endpoint.os, agent.uuid, src.process.name, tgt.process.cmdline
// HUNT: multi-class credential sweep by shell children on Linux
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1003.007, T1552.001, T1552.005, T1552.007
// CONF: medium | FP: medium
// FALSE POSITIVES: configuration management, backup agents, cloud-init style scripts, monitoring collectors
// EQUIVALENCE: approximate-analytic - Same behaviour, different analytic: PowerQuery has no case expression, so each of the six sweep classes is counted per host with the CQL's first-match order kept by excluding earlier classes, and a host qualifies when any two class counts are above zero. The CQL's collected class-name list is replaced by the per-class counts.
// REQUIRES: SentinelOne Linux process-creation events with command line; best on self-hosted runner hosts (see Q9)
// TUNING: Restrict to runner hosts from Q9 by endpoint.name; exclude known automation accounts; require three classes on noisy hosts by adding class pairs to the final filter.
// LOOKBACK: 7d (set the window with the SentinelOne time picker)
// CONFIGURATION: No lookup objects or data tables required.
// KNOWN GAPS: The result lists a count per class, not the CQL's collected class names. A command line that mentions a path without any of the listed markers is not counted.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' endpoint.os = 'linux' src.process.name in ('bash', 'sh', 'dash') tgt.process.cmdline matches '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'
| group cmds=count(), n_environ=count(tgt.process.cmdline matches 'environ'), n_metadata=count(tgt.process.cmdline matches '169[.]254[.]169[.]254|metadata\\.google\\.internal' and !(tgt.process.cmdline matches 'environ')), n_sa_token=count(tgt.process.cmdline matches 'serviceaccount' and !(tgt.process.cmdline matches 'environ|169[.]254[.]169[.]254|metadata\\.google\\.internal')), n_docker_sock=count(tgt.process.cmdline matches 'docker\\.sock' and !(tgt.process.cmdline matches 'environ|169[.]254[.]169[.]254|metadata\\.google\\.internal|serviceaccount')), n_runner_creds=count(tgt.process.cmdline matches 'credentials_rsaparams|runner_credentials' and !(tgt.process.cmdline matches 'environ|169[.]254[.]169[.]254|metadata\\.google\\.internal|serviceaccount|docker\\.sock')), n_files_keys=count(!(tgt.process.cmdline matches 'environ|169[.]254[.]169[.]254|metadata\\.google\\.internal|serviceaccount|docker\\.sock|credentials_rsaparams|runner_credentials')) by agent.uuid, endpoint.name
| filter (n_environ > 0 and n_metadata > 0) or (n_environ > 0 and n_sa_token > 0) or (n_environ > 0 and n_docker_sock > 0) or (n_environ > 0 and n_runner_creds > 0) or (n_environ > 0 and n_files_keys > 0) or (n_metadata > 0 and n_sa_token > 0) or (n_metadata > 0 and n_docker_sock > 0) or (n_metadata > 0 and n_runner_creds > 0) or (n_metadata > 0 and n_files_keys > 0) or (n_sa_token > 0 and n_docker_sock > 0) or (n_sa_token > 0 and n_runner_creds > 0) or (n_sa_token > 0 and n_files_keys > 0) or (n_docker_sock > 0 and n_runner_creds > 0) or (n_docker_sock > 0 and n_files_keys > 0) or (n_runner_creds > 0 and n_files_keys > 0)
| sort -cmds
| limit 1000Automated validation: static-passed
4 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeInitiatingProcessFileName: Initiating processProcessCommandLine: Created command lineDeviceId: Device identifier
Recommended fields
DeviceName: Device name
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, DeviceId, InitiatingProcessFileName, ProcessCommandLine | take 5
// HUNT: multi-class credential sweep by shell children on Linux
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1003.007, T1552.001, T1552.005, T1552.007
// CONF: medium | FP: medium
// FALSE POSITIVES: configuration management, backup agents, cloud-init style scripts, monitoring collectors
// EQUIVALENCE: approximate-analytic - Same sweep-class analytic: shell children whose command line touches two or more of six credential-source classes, classified first-match as in the CQL. DeviceProcessEvents has no operating-system column, so the Linux restriction is carried only by the Linux-specific markers and the shell-parent filter.
// REQUIRES: Defender for Endpoint Linux process-creation telemetry (DeviceProcessEvents); best on self-hosted runner hosts (see Q9)
// TUNING: Restrict to runner hosts from Q9 by DeviceName; exclude known automation accounts; raise Classes to 3 on noisy hosts.
// LOOKBACK: 7d; extend to 30d for the incident window
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: No operating-system filter: a Windows or macOS shell child that mentions a listed marker is counted.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed-with-warnings; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(7d)
| where InitiatingProcessFileName in ("bash", "sh", "dash")
| where ProcessCommandLine matches regex @"(?i)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"
| extend SweepClass = case(ProcessCommandLine contains "environ", "proc-environ", ProcessCommandLine matches regex @"(?i)169\.254\.169\.254|metadata\.google\.internal", "cloud-metadata", ProcessCommandLine contains "serviceaccount", "k8s-sa-token", ProcessCommandLine matches regex @"(?i)docker\.sock", "docker-socket", ProcessCommandLine matches regex @"(?i)credentials_rsaparams|runner_credentials", "runner-creds", "files-and-keys")
| summarize Cmds = count(), Classes = dcount(SweepClass), SweepClasses = make_set(SweepClass), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by DeviceId, DeviceName
| where Classes >= 2
| sort by Classes desc, Cmds desc
| take 200Looks 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.
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])
4 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typetgt.process.name: New process namesrc.process.name: Creator process nametgt.process.cmdline: New process command line
Recommended fields
endpoint.name: Host name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns event.time, event.type, tgt.process.name, src.process.name, tgt.process.cmdline
// HUNT: docker run with host root mounted, parent is a shell
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1611
// CONF: medium | FP: medium
// FALSE POSITIVES: node diagnostics, backup and forensic containers started by administrators
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: docker run started by bash, sh or dash with the host root directory mounted by a volume flag or a mount source of /. The new process is tgt.process.*; its creator is src.process.*. The flag regexes are case-sensitive, as in the CQL.
// REQUIRES: SentinelOne Linux process-creation events with command line
// TUNING: Exclude the administrator accounts and jump hosts that run these tools, by src.process.user or endpoint.name.
// LOOKBACK: 30d (set the window with the SentinelOne time picker)
// CONFIGURATION: No lookup objects or data tables required.
// KNOWN GAPS: A container started through the Docker API, a compose file or a non-shell parent is not matched.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' tgt.process.name in:anycase ('docker') src.process.name in ('bash', 'sh', 'dash') tgt.process.cmdline matches:matchcase '\\brun\\b' tgt.process.cmdline matches:matchcase '(-v|--volume)[ =]/:|--mount.*(source|src)=/(,|\\s|$)'
| columns event.time, agent.uuid, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline
| sort -event.time
| limit 200Automated validation: static-passed
4 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeFileName: Created process nameInitiatingProcessFileName: Initiating processProcessCommandLine: Created command line
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, FileName, InitiatingProcessFileName, ProcessCommandLine | take 5
// HUNT: docker run with host root mounted, parent is a shell
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1611
// CONF: medium | FP: medium
// FALSE POSITIVES: node diagnostics, backup and forensic containers started by administrators
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: docker run created by bash, sh or dash with the host root directory mounted by a volume flag or a mount source of /. The creator is InitiatingProcessFileName. The flag regexes are case-sensitive, as in the CQL.
// REQUIRES: Defender for Endpoint Linux process-creation telemetry (DeviceProcessEvents)
// TUNING: Exclude the administrator accounts and jump hosts that run these tools, by AccountName or DeviceName.
// LOOKBACK: 30d (Advanced Hunting retention is 30 days)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A container started through the Docker API, a compose file or a non-shell parent is not matched.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(30d)
| where FileName =~ "docker"
| where InitiatingProcessFileName in ("bash", "sh", "dash")
| where ProcessCommandLine matches regex @"\brun\b"
| where ProcessCommandLine matches regex @"(-v|--volume)[ =]/:|--mount.*(source|src)=/(,|\s|$)"
| project Timestamp, ReportId, DeviceId, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName
| sort by Timestamp desc
| take 200Looks 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.
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])
3 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typetgt.process.name: New process nametgt.process.cmdline: New process command line
Recommended fields
endpoint.name: Host namesrc.process.name: Creator process name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns event.time, event.type, tgt.process.name, tgt.process.cmdline
// HUNT: GitHub token store paths in command lines of non-git tools // SCHEMA PROFILE: sentinelone-endpoint-ocsf // MITRE: T1528, T1552.001 // CONF: low | FP: medium // FALSE POSITIVES: backup agents, dotfile managers, credential helpers, migration scripts // EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: a GitHub token store path in the command line of a process that is not git, gh or an approved helper. matches is case-insensitive, which reproduces the CQL /i intent. // REQUIRES: SentinelOne process-creation events with command line on developer endpoints // TUNING: Extend the process-name exclusion with your approved credential helpers and editors; alert only when the creator is a script host or an unsigned binary. // LOOKBACK: 30d (set the window with the SentinelOne time picker) // CONFIGURATION: No lookup objects or data tables required. // KNOWN GAPS: A token store read through an API call that never appears in a command line is not matched. // VALIDATION LEVEL: static-reviewed event.type = 'Process Creation' tgt.process.cmdline matches '[.]git-credentials|gh.hosts\\.yml|git:https:..github[.]com' !(tgt.process.name matches '^(git|gh|git-credential[-a-z]*|git-remote-https|code|cursor|ssh|ssh-agent)([.]exe)?$') | columns event.time, agent.uuid, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline | sort -event.time | limit 200
Automated validation: static-passed
3 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeFileName: Created process nameProcessCommandLine: Created command line
Recommended fields
InitiatingProcessFileName: Initiating process
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, FileName, ProcessCommandLine | take 5
// HUNT: GitHub token store paths in command lines of non-git tools // SCHEMA PROFILE: microsoft-defender-xdr // MITRE: T1528, T1552.001 // CONF: low | FP: medium // FALSE POSITIVES: backup agents, dotfile managers, credential helpers, migration scripts // EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: a GitHub token store path in the command line of a process that is not git, gh or an approved helper. The (?i) prefix reproduces the CQL /i intent. // REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) on developer endpoints // TUNING: Extend the process-name exclusion with your approved credential helpers and editors; alert only when the initiating process is a script host or an unsigned binary. // LOOKBACK: 30d (Advanced Hunting retention is 30 days) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: A token store read through an API call that never appears in a command line is not matched. // VALIDATION LEVEL: schema-aligned // AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0 DeviceProcessEvents | where Timestamp >= ago(30d) | where ProcessCommandLine matches regex @"(?i)\.git-credentials|gh.hosts\.yml|git:https:..github\.com" | where not (FileName matches regex @"(?i)^(git|gh|git-credential[-a-z]*|git-remote-https|code|cursor|ssh|ssh-agent)(\.exe)?$") | project Timestamp, ReportId, DeviceId, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName | sort by Timestamp desc | take 200
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.
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])
2 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typetgt.process.cmdline: New process command line
Recommended fields
endpoint.name: Host nametgt.process.name: New process name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.cmdline = * | limit 5 | columns event.time, event.type, tgt.process.name
// HUNT: GitHub token literal in a process command line (redacted)
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1528, T1552.001
// CONF: medium | FP: low
// FALSE POSITIVES: placeholder strings in documentation commands that match the format; test fixtures
// EQUIVALENCE: approximate-analytic - Same token-format predicate on the new process's command line. The CQL projects a redacted copy of the command line; PowerQuery has no offline-checked redaction here, so the command line is not projected at all and the token is never displayed in the result.
// REQUIRES: SentinelOne process-creation events with command line
// TUNING: Exclude documented placeholder strings and test fixtures by endpoint.name after review.
// LOOKBACK: 30d (set the window with the SentinelOne time picker)
// CONFIGURATION: No lookup objects or data tables required.
// KNOWN GAPS: 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.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' tgt.process.cmdline matches:matchcase '\\bgh[pousr]_[A-Za-z0-9]{36}\\b|github_pat_[A-Za-z0-9_]{22,}'
| columns event.time, agent.uuid, endpoint.name, src.process.user, src.process.name, tgt.process.name
| sort -event.time
| limit 200Automated validation: static-passed
2 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeProcessCommandLine: Created command line
Recommended fields
FileName: Created process nameInitiatingProcessFileName: Initiating process
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, FileName, ProcessCommandLine | take 5
// HUNT: GitHub token literal in a process command line (redacted)
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1528, T1552.001
// CONF: medium | FP: low
// FALSE POSITIVES: placeholder strings in documentation commands that match the format; test fixtures
// EQUIVALENCE: equivalent-with-schema-assumptions - Same token-format predicate on the created process's command line, with the token replaced by a marker in the projected copy as in the CQL. The regex is case-sensitive, as in the CQL.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents)
// TUNING: Exclude documented placeholder strings and test fixtures by DeviceName after review.
// LOOKBACK: 30d (Advanced Hunting retention is 30 days)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: The raw command line, with the token, remains in the underlying event and in any query that projects ProcessCommandLine.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(30d)
| where ProcessCommandLine matches regex @"\bgh[pousr]_[A-Za-z0-9]{36}\b|github_pat_[A-Za-z0-9_]{22,}"
| extend CmdRedacted = replace_regex(ProcessCommandLine, @"(gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,})", "[REDACTED_GITHUB_TOKEN]")
| project Timestamp, ReportId, DeviceId, DeviceName, AccountName, FileName, CmdRedacted, InitiatingProcessFileName
| sort by Timestamp desc
| take 200Looks 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.
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)
3 required fields · View requirements
Required telemetry
- Process Creation events
Required fields
event.type: Event typetgt.process.name: New process nameagent.uuid: Host identifier
Recommended fields
endpoint.name: Host name
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns event.time, event.type, agent.uuid, tgt.process.name
// HUNT: inventory of hosts running the Actions runner listener or worker // SCHEMA PROFILE: sentinelone-endpoint-ocsf // MITRE: T1059.004 // CONF: high | FP: low // FALSE POSITIVES: not a detection; every hit is an expected runner host // EQUIVALENCE: equivalent-with-schema-assumptions - Same inventory: hosts that started the Actions runner listener or worker, with a start count and first and last seen. matches is case-insensitive, which reproduces the CQL /i intent. // REQUIRES: SentinelOne process-creation events on candidate runner hosts // TUNING: Not a detection: every row is an expected runner host. Compare the list with the approved runner inventory. // LOOKBACK: 30d (set the window with the SentinelOne time picker) // CONFIGURATION: No lookup objects or data tables required. // KNOWN GAPS: A runner started before the lookback window and never restarted is not listed. // VALIDATION LEVEL: static-reviewed event.type = 'Process Creation' tgt.process.name matches '^Runner[.](Listener|Worker)([.]exe)?$' | group starts=count(), first_seen=min(event.time), last_seen=max(event.time) by agent.uuid, endpoint.name | sort -last_seen | limit 1000
Automated validation: static-passed
3 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeFileName: Created process nameDeviceId: Device identifier
Recommended fields
DeviceName: Device name
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, FileName, DeviceId | take 5
// HUNT: inventory of hosts running the Actions runner listener or worker // SCHEMA PROFILE: microsoft-defender-xdr // MITRE: T1059.004 // CONF: high | FP: low // FALSE POSITIVES: not a detection; every hit is an expected runner host // EQUIVALENCE: equivalent-with-schema-assumptions - Same inventory: devices that started the Actions runner listener or worker, with a start count and first and last seen. The (?i) prefix reproduces the CQL /i intent. // REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) on candidate runner hosts // TUNING: Not a detection: every row is an expected runner host. Compare the list with the approved runner inventory. // LOOKBACK: 30d (Advanced Hunting retention is 30 days) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: A runner started before the lookback window and never restarted is not listed. // VALIDATION LEVEL: schema-aligned // AUTOMATED VALIDATION: static-passed; static=passed-with-warnings; fixtures=0/0 DeviceProcessEvents | where Timestamp >= ago(30d) | where FileName matches regex @"(?i)^Runner\.(Listener|Worker)(\.exe)?$" | summarize Starts = count(), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by DeviceId, DeviceName | sort by LastSeen desc | take 1000
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 name | Rule type | Pattern | Exclusions | Promotes | Deployment path |
|---|---|---|---|---|---|
| Gheist helper files or gdmp-pivot in a command line | Process Creation | Command line matches .gdmp_pat, .gdmp_patg, .gdmp_sweep or gdmp-pivot | None expected; IR workstation group after first review | Promote Q1 | Detect on runner hosts; no prevent because the evidence is one source |
| base32 with 120-column wrap spawned by a shell | Process Creation | Image name base32; command line contains -w 120 or --wrap 120; parent image bash, sh or dash | Documented build scripts that wrap at 120 after baseline | Promote Q3 | Detect 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
| Rule | Source query | Run as | Notes |
|---|---|---|---|
| Gheist helper files or gdmp-pivot in a command line | Q1, PowerQuery tab | Scheduled custom detection rule | Bind 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 shell | Q3, PowerQuery tab | Scheduled custom detection rule | Bind 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
| Rule | Source query | Run as | Notes |
|---|---|---|---|
| Gheist helper files or gdmp-pivot in a command line | Q1, KQL – Defender XDR tab | Custom detection rule, alert only, every hour | The 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 shell | Q3, KQL – Defender XDR tab | Custom detection rule, alert only, every hour | The 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.
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.
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.
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 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 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 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 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.
Detection Validation Gates
| Gate | Check | Pass condition |
|---|---|---|
| Telemetry ready | Process 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 baseline | Run 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 tests | In 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. |
| Promotion | Promote 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. |
Hardening: Tiered & Deployable
- 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.
- 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.
- 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.
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.
Containment Runbook
| Phase | Actions | Owner | Evidence |
|---|---|---|---|
| Isolate | Revoke 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 owner | Token revocation record, containment timestamp, list of affected repositories |
| Scope | Run 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 / IR | Branch list with commit ids, audit-log extract, secret inventory per repository |
| Eradicate | Rotate 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 owners | Rotation ticket per secret class, rebuild record |
| Recover | Re-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 engineering | Environment protection config, OIDC policy diff, audit-log alert test |
| Evidence | Preserve audit-log exports, branch and commit ids, runner _diag logs and process telemetry before deleting branches or rebuilding hosts. | IR | Hashes of exported files, chain-of-custody note |
Detection Coverage Map
| Technique | Behavior | CQL | IOA | CrowdStrike coverage | SentinelOne | Defender XDR |
|---|---|---|---|---|---|---|
| T1078.004 / T1528 | Stolen tokens used by the tool from attacker infrastructure | GAP | GAP | GAP | ||
| T1213.003 | Private repositories cloned before the plant | GAP | GAP | GAP | ||
| T1070 | Run deleted seconds after the log is downloaded; gheist user agent | GAP | GAP | GAP | ||
| T1567 | Secrets exfiltrated through the GitHub run log and API | GAP | GAP | GAP | ||
| T1059.004 / T1027 | Workflow step runs base32 on the environment (self-hosted runners only) | Q3 | Q3 | Partial | Partial: same limit as CrowdStrike | Partial: same limit as CrowdStrike |
| T1552.001 | Sweep helper files and gdmp-pivot header | Q1, Q2 | Q1 | Partial | Partial: Q2 approximate | Partial: Q2 approximate |
| T1003.007 / T1552.005 / T1552.007 | Environment, metadata and service-account token sweep | Q5 | Partial | Partial: Q5 approximate | Partial: Q5 approximate | |
| T1611 | Container with host root mounted | Q6 | Partial | Partial: same limit as CrowdStrike | Partial: same limit as CrowdStrike | |
| T1078.004 | git or gh naming a ci-sync branch on a covered host | Q4 | Partial | Partial: same limit as CrowdStrike | Partial: same limit as CrowdStrike | |
| T1528 / T1552.001 | Token stores read or token pasted into a command on developer endpoints | Q7, Q8 | Partial | Partial: Q8 approximate | Partial: same limit as CrowdStrike | |
| T1555.006 | AWS Secrets Manager enumeration with cloud credentials | GAP | GAP | GAP |
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.
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-26Changelog
References
| Tier | Source | Used for | Access date |
|---|---|---|---|
| T1 | Wiz Research: Gheist, Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles | Primary source for the whole pack: chain, variants, indicators, hunt and response guidance. | 2026-10-26 |
| T2 | The Hacker News: Gheist Uses Stolen GitHub Tokens to Steal Private Repository Secrets via Actions Logs | Second write-up, GitHub quotes on the hold and on write access. | 2026-10-26 |
| T1 | GitHub changelog: Actions holds potentially malicious workflows for approval | Scope of the approval hold. | 2026-10-26 |
| T3 | Safeguard: Gheist, Stolen GitHub Tokens and the sync-check Secret Dump | Cross-check of indicators and the OIDC subject format. | 2026-10-26 |
| T3 | SecurityOnline: Gheist Campaign Abuses Stolen GitHub Tokens Across 850 Accounts | Corroborating summary. | 2026-10-26 |
| T3 | Mallory: Gheist Abuses Stolen GitHub Tokens to Steal Actions Secrets and Target Runners | Timeline cross-check. | 2026-10-26 |
Generated 2026-10-26 by HuntPack. Defensive content only.