Skip to main content
RunBook Academy

Git, CI/CD & GitOpsLXXI · CI/CD Threat ModellingAttack

The runner-compromise attack — the persistent-access scenario

Advanced⏱ ~25 mingit

What you'll learn

  • Describe the runner-compromise attack: a malicious workflow step persists in the runner and exfiltrates on every subsequent build
  • Distinguish a hosted ephemeral runner from a self-hosted persistent runner by the cost of persistence
  • Identify the persistence signals: cron jobs, SSH keys left in ~/.ssh, modified build tools, credentials cached in the runner environment
  • Apply controls — ephemeral runners, step isolation, runner image rebuilds, network egress controls — that make persistence expensive

Prerequisites

Practice

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

The runner-compromise attack is the scenario where the attacker persists in the build environment. The attacker compromises a single workflow step — through a malicious dependency, a vulnerable step, a leaked secret, or a typosquatted command — and the runner’s persistent filesystem, long-lived credentials, and shared state across builds turn the single compromise into a foothold. The attacker who compromises the runner today has access tomorrow, the day after, and every day until the runner is rebuilt. The runner-compromise attack is the scenario where a single step’s compromise becomes the team’s standing compromise.

The persistence mechanisms

A runner that is reused across builds has four persistence mechanisms. Each mechanism is a place the attacker can write to and return through.

  • Filesystem. The runner’s filesystem persists between builds. A malicious step can write a backdoor to /home/runner/.bashrc, to a cron directory, to a systemd unit, or to a ~/.ssh/authorized_keys file. The backdoor survives the build; the backdoor is invoked by the next build, by a scheduled cron, or by an external SSH login.
  • Credentials. The runner holds long-lived credentials — a PAT, a cloud access key, a signing key, an SSH key. The credentials are in the runner’s environment, in the runner’s filesystem, or in the runner’s memory. The attacker reads the credentials once and uses them from anywhere.
  • Build tools. The runner has a ~/.npmrc, ~/.pip/pip.conf, ~/.docker/config.json, or ~/.gitconfig that points to an attacker-controlled registry or mirror. The next build resolves a dependency through the malicious mirror. The attack survives the build because the configuration survives.
  • Container runtime. The runner runs Docker; the attacker leaves a privileged container or a malicious image in the local registry. The next build pulls from the local cache and executes the attacker’s bytes.
flowchart LR
    A["Compromise one step"] --> B["Filesystem write"]
    A --> C["Credential exfil"]
    A --> D["Build tool config"]
    A --> E["Container persistence"]
    B --> F["Survives build"]
    C --> F
    D --> F
    E --> F
    F --> G["Compromise next build"]

The four mechanisms share a property: the persistence survives the build. The attacker who writes to any one of them has written to a foothold that will be there when the next build runs.

The attack in three steps

The runner-compromise attack has three steps. The first step is the initial compromise; the second is the persistence; the third is the return.

  1. Initial compromise. The attacker compromises one workflow step. The compromise vector is one of the other surfaces — a malicious dependency (Part LXXI-04), a stolen credential (Part LXXI-03), a malicious commit (Part LXXI-02). The step runs the attacker’s payload with the build’s privileges.
  2. Persistence. The payload writes to one of the four persistence mechanisms. A ~/.bashrc drop, an SSH key in authorized_keys, a cron entry, a modified ~/.npmrc, or a privileged container. The persistence is invisible to the current build; the persistence is waiting for the next build, the next login, or the next cron tick.
  3. Return. The attacker returns through the persistence mechanism. The next build executes the backdoor in ~/.bashrc; the attacker logs in via the planted SSH key; the cron runs the payload on schedule. The attacker has continuous access to the team’s build environment.
flowchart LR
    A["Compromise step"] --> B["Write to filesystem / config"]
    B --> C["Survives build"]
    C --> D["Next build triggers payload"]
    D --> E["Attacker has continuous access"]

The attack’s signature is its invisibility to the build log. The build log shows the current build’s steps; the build log does not show the cron entry, the SSH key, the modified .npmrc, or the privileged container. The attacker returns through a path the build log does not record.

Hosted versus self-hosted: the persistence asymmetry

The two deployment models for CI runners have different exposures to the persistence attack.

  • Hosted ephemeral runners (GitHub Actions, GitLab.com CI, Buildkite cloud) are created per-job and destroyed after. The runner’s filesystem does not persist; the runner’s credentials do not persist; the runner’s configuration does not persist. The attacker who compromises a hosted ephemeral runner has compromised a single build; the attacker cannot persist because there is nothing to persist to.
  • Self-hosted persistent runners are long-lived VMs or containers that the team manages. The runner’s filesystem persists between builds; the runner’s credentials persist; the runner’s configuration persists. The attacker who compromises a self-hosted persistent runner has compromised a foothold that survives the build.

The asymmetry is the structural argument for hosted ephemeral runners. The hosted model closes the persistence attack by construction; the self-hosted model requires the team to construct the closure.

Detection signals

The signals that distinguish a runner compromise from a clean build are not in the build log; they are in the runner’s state between builds.

  • Cron jobs that the team did not configure. The attacker’s payload writes a cron entry; the entry runs on schedule; the entry is not in the team’s configuration management. The signal is the diff between the runner’s crontab and the team’s configuration.
  • SSH keys in ~/.ssh/authorized_keys that the team did not add. The attacker’s payload plants a key; the key allows the attacker to log in from outside the CI workflow. The signal is the diff between the runner’s authorized_keys and the team’s known-good set.
  • Modified ~/.npmrc, ~/.pip/pip.conf, or ~/.docker/config.json. The attacker’s payload points the runner at a malicious registry; the next build resolves through the malicious registry. The signal is the diff between the runner’s configuration files and the team’s baseline.
  • Credentials cached in the runner’s environment. The attacker’s payload reads the runner’s environment and exfiltrates the credentials. The signal is the audit log of the credential’s use from an IP not on the team’s allowlist.

The four signals require the team to inspect the runner’s state between builds. Most teams do not inspect the runner; most teams do not have a baseline to diff against; most teams discover the compromise when the attacker returns through a path the build log does not record.

Controls that make a runner a one-shot environment

The controls that close the runner-compromise attack fall into four groups.

  1. Ephemeral runners. The runner is created for the build and destroyed after. The filesystem, the credentials, and the configuration do not persist. The attacker has nothing to persist to.
  2. Step isolation. Each build step runs in a fresh container with no shared filesystem and no shared state with the previous step. The attacker cannot read secrets from one step in another; the attacker cannot write to a filesystem the next step will read.
  3. Runner image rebuilds. The self-hosted runner’s base image is rebuilt from a clean source on a fixed interval — daily, weekly, or per-build. The attacker who persists in the runner’s filesystem has a persistence that expires on schedule.
  4. Network egress controls. The runner’s outbound network access is restricted to the registries and endpoints the build requires. The attacker who exfiltrates credentials cannot reach the exfiltration endpoint because the endpoint is not on the allowlist.

The four groups together close the four persistence mechanisms. Ephemeral runners close the filesystem persistence. Step isolation closes the cross-step leak. Runner image rebuilds close the long-lived filesystem. Network egress controls close the credential exfiltration.

Production discipline

  1. Use hosted ephemeral runners where possible. The runner is created for the build and destroyed after; the persistence attack is closed by construction.
  2. If self-hosted, rebuild the runner image on a schedule. Daily, weekly, or per-build; the attacker’s persistence expires on the schedule.
  3. Isolate every step in a fresh container. No shared filesystem, no shared state, no cross-step leak.
  4. Restrict network egress to an allowlist. The runner reaches only the registries and endpoints the build requires; the exfiltration endpoint is not on the list.

Cross-course references

  • Git, CI/CD & GitOps — Part LXXI-04 (Dependency Compromise) is the upstream attack this lesson assumes.
  • Git, CI/CD & GitOps — Part LXV-04 (CI Trust and the Runner as an Actor) covers the runner trust model in detail.
  • Git, CI/CD & GitOps — Part XXXVII (CI Foundations) covers the runner model in detail.
  • Linux for Production Sysadmins — Part XXVIII (Service Hardening) covers the OS-level runner hardening pattern.

Quiz

Knowledge check · 4 questions

  1. Q1. Why is a self-hosted persistent runner structurally more vulnerable to the runner-compromise attack than a hosted ephemeral runner?

  2. Q2. A team that hardens a self-hosted runner with anti-virus, intrusion detection, and file-integrity monitoring has structurally closed the runner-compromise attack.

  3. Q3. Name the four persistence mechanisms a self-hosted runner exposes, and the control that closes each mechanism.

  4. Q4. Diagnose the runner-compromise attack, identify the persistence mechanism, and recommend the controls that close the gap.

    Team T runs a self-hosted GitHub Actions runner on a long-lived VM in the team's VPC. The runner is reused across hundreds of jobs; the runner's filesystem persists between jobs. The runner has a long-lived PAT to push to ghcr.io and a long-lived AWS access key for production deploys; both are stored in the runner's environment. A developer adds a workflow step that pulls a third-party action by tag (`@v3`). The action's maintainer is compromised; the malicious action's payload writes an SSH public key to ~/.ssh/authorized_keys and a cron entry that runs every 15 minutes. The next 30 builds all execute the cron payload, which reads the AWS access key from the runner's environment and posts it to an attacker-controlled endpoint.

Passing score: 75%. Answers are checked in this browser.