Safeguard
Concepts

What is a Build Cache Poisoning Attack

Build cache poisoning plants malicious entries in a shared CI cache so trusted builds unknowingly consume attacker-controlled artifacts. Here's the mechanics and the fixes.

Sofia Marchetti
Open Source Program Lead
6 min read

A build cache poisoning attack occurs when an attacker writes a malicious entry into a cache that trusted builds later restore — compiled objects, dependency archives, Docker layers, or tool binaries — so that a build pipeline which never runs attacker code directly still incorporates attacker-controlled bytes into its output. The defining feature is privilege escalation across a trust boundary: low-privilege contexts (a fork's pull request, a compromised feature branch, a shared runner) get write access to a cache that high-privilege contexts (release builds on main) read from. The cache becomes the bridge.

It's an attractive attack precisely because everything downstream still looks clean. Source review passes, the release workflow is untouched, signatures get applied — to an artifact that was assembled from poisoned parts.

Why caches are a trust boundary nobody drew

Build caches exist to skip work: don't recompile unchanged code, don't re-download unchanged dependencies. Skipping work means trusting that the cached bytes equal the bytes the skipped work would have produced. That trust assumption is load-bearing and mostly unexamined, because caches are keyed by strings — and whoever can write an entry under the right key gets their bytes treated as the legitimate computation's output.

GitHub Actions is the canonical study. actions/cache scopes caches by branch, but with a deliberate inheritance rule: branches read the default branch's caches. The reverse should never happen — except several attack chains have made it happen:

  • Cache poisoning via pull_request_target and workflow injection (research by Adnan Khan and others, 2023–2024). Workflows that run privileged on PR-adjacent triggers can be tricked into executing attacker input; once you execute in a context with cache-write scope on main, you can stuff any key. This chain hit real infrastructure — the same researcher's PyTorch and Actions Runner Controller disclosures showed poisoned caches leading to release compromise paths.
  • Cacheract-style persistence (2024). Once one poisoned entry executes inside a workflow, it can re-poison other keys on every run — malware that lives only in the cache, surviving with no commit anywhere. Auditing the repo finds nothing, because the repo is fine.
  • The March 2025 tj-actions/changed-files compromise began with exactly this class of technique against a related action's CI, then propagated to 23,000+ repositories. Cache trust was a link in the chain that turned one workflow bug into an ecosystem event.

The flavors, beyond GitHub Actions

Cache typePoisoning vectorBlast radius
CI dependency cache (actions/cache, GitLab cache)Cross-branch/key writes from low-privilege runsAny job restoring the key
Remote compile caches (sccache, Bazel remote cache, Gradle remote build cache)Any client with write creds injects objects under a hash keyEvery developer and CI build sharing the cache
Docker layer cache (--cache-from, registry-backed BuildKit cache)Push access to the cache ref substitutes layersAll images built atop the poisoned layer
Package manager caches on shared runnersPrevious job on the same self-hosted runner seeds ~/.m2, ~/.npm, ~/.cache/pipEvery subsequent job on that runner
Go module proxy / registry-side cachesPoison at first-fetch, immutability preserves itEveryone resolving through that proxy, indefinitely

The shared remote compile cache case deserves a special callout: Bazel and Gradle remote caches are keyed by input hashes, and clients typically trust cache hits completely. If every laptop in engineering holds write credentials to the remote cache — a common convenience configuration — then compromising any single developer machine converts to arbitrary code in everyone's builds. Mozilla and Google run their caches with CI-only writers for exactly this reason.

Defenses that actually hold

The principles are boring and effective: separate writers from readers, bind keys to content, and make release builds independently verifiable.

  1. Never restore caches in release/signing jobs. A cold build costs minutes; it also makes the release path immune to this entire attack class. If you do nothing else, do this.
  2. Write-restrict shared caches. Remote compile caches: CI writes, everyone else reads (sccache and Bazel both support read-only client modes). GitHub Actions: understand that anything achieving execution in a main-scoped workflow can write main-scoped caches — so harden those workflows against injection first (no pull_request_target with checkout-of-PR-head, no untrusted input in run: blocks).
  3. Key hygiene. Cache keys should incorporate a hash of the complete inputs (hashFiles('**/package-lock.json')), not guessable static strings, and jobs should treat restore-keys prefix fallbacks as the risk surface they are — a fallback match is a cache entry you didn't fully specify.
  4. Isolate runners between trust levels. Ephemeral runners per job eliminate the runner-local seeding vector. Never let public-fork PRs share self-hosted runners with anything that matters.
  5. Verify what the cache hands back where feasible. Dependency archives restored from cache can be re-checked against lockfile hashes (npm ci does this; pip --require-hashes does too). Compiled-object caches can't be, which is why writer restriction carries the weight there.
  6. Provenance as the backstop. Signed build provenance won't stop the poisoning, but hermetic, cache-cold rebuilds that reproduce the release artifact will detect it — divergence between two builds of the same commit is the alarm. SLSA Level 3 isolation requirements exist substantially because of this attack class.

Monitoring closes the loop: cache-write events from unexpected workflows or branches are high-signal and low-volume, which is the best kind of alert. If your pipeline security tooling models the CI layer — Safeguard's pipeline analysis flags workflows that combine untrusted triggers with cache writes and privileged follow-on jobs — the risky combinations are enumerable in advance rather than discovered by incident.

Frequently asked questions

How is cache poisoning different from dependency confusion?

Dependency confusion exploits name resolution — tricking the package manager into fetching an attacker's package from the wrong registry. Cache poisoning exploits storage trust — the right name, the right key, but attacker bytes behind it. Confusion is defeated by registry scoping; poisoning is defeated by write isolation and cold release builds.

Are GitHub-hosted runners safe from this?

Hosted runners eliminate the runner-local seeding vector (fresh VM per job), but actions/cache trust rules still apply: anything that executes in a default-branch context can write caches that other default-branch jobs restore. Workflow injection bugs remain the practical entry point, hosted or not.

How would I even detect a poisoned cache?

Three ways, in increasing rigor: audit cache-write events against expected writers (GitHub exposes cache usage via API); periodically rebuild with --no-cache and diff outputs against cached builds; and for releases, require reproducible builds where a second, hermetic build must match the shipped digest. Silent divergence is the tell.

Does Safeguard or any SCA tool catch this?

Manifest-based SCA alone doesn't — the poisoned bytes never appear in a manifest. What helps is CI/CD posture analysis (dangerous trigger-plus-cache-write combinations), provenance verification, and artifact diffing between cached and hermetic builds. Treat cache poisoning as a build-integrity problem, not a dependency-inventory one.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.