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_targetand 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 onmain, 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-filescompromise 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 type | Poisoning vector | Blast radius |
|---|---|---|
CI dependency cache (actions/cache, GitLab cache) | Cross-branch/key writes from low-privilege runs | Any 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 key | Every developer and CI build sharing the cache |
Docker layer cache (--cache-from, registry-backed BuildKit cache) | Push access to the cache ref substitutes layers | All images built atop the poisoned layer |
| Package manager caches on shared runners | Previous job on the same self-hosted runner seeds ~/.m2, ~/.npm, ~/.cache/pip | Every subsequent job on that runner |
| Go module proxy / registry-side caches | Poison at first-fetch, immutability preserves it | Everyone 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.
- 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.
- Write-restrict shared caches. Remote compile caches: CI writes, everyone else reads (
sccacheand Bazel both support read-only client modes). GitHub Actions: understand that anything achieving execution in amain-scoped workflow can writemain-scoped caches — so harden those workflows against injection first (nopull_request_targetwith checkout-of-PR-head, no untrusted input inrun:blocks). - Key hygiene. Cache keys should incorporate a hash of the complete inputs (
hashFiles('**/package-lock.json')), not guessable static strings, and jobs should treatrestore-keysprefix fallbacks as the risk surface they are — a fallback match is a cache entry you didn't fully specify. - 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.
- Verify what the cache hands back where feasible. Dependency archives restored from cache can be re-checked against lockfile hashes (
npm cidoes this;pip --require-hashesdoes too). Compiled-object caches can't be, which is why writer restriction carries the weight there. - 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.