A developer opens a pull request. An automated CI/CD (Continuous Integration/Continuous Deployment) job runs its code with cache-write access. Hours later, a release job restores the poisoned cache and executes attacker-controlled content.

TanStack’s May 2026 package compromise and an earlier Angular research finding show why this boundary matters. GitHub has since made the default cache access for low-trust triggers read-only, so the workflow conditions now determine whether the historical chain remains possible.

TL;DR

  • GitHub currently gives low-trust triggers, including pull_request_target, read-only access to the default branch cache unless a workflow explicitly opts into write access.
  • Historical cache-poisoning cases show what can happen when untrusted PR code can write a cache later restored by a privileged workflow.
  • Angular and TanStack illustrate the cache-poisoning chain; the tj-actions compromise was a separate malicious-action supply-chain incident.
  • Audit pull_request_target code execution, cache-mode overrides, cache saves and release cache restores together.

Why This Matters

If your project uses GitHub Actions for builds and releases, your CI/CD pipeline is almost certainly a target. GitHub Actions is the dominant CI platform for open source — which means supply chain attackers have heavily focused on it.

Cache poisoning does not require a vulnerability in the cache service itself. It requires a trust-boundary failure: untrusted code must be able to write a cache entry that a more privileged workflow later restores. GitHub’s current default blocks writes to the default branch cache from low-trust triggers; an explicit write-capable cache-mode override can reintroduce the risk.


How GitHub Actions Caching Works

Before the attack, the mechanism:

GitHub Actions allows workflows to save and restore build artifacts (dependencies, compiled outputs) using actions/cache. Cache entries are keyed by a hash — typically derived from a lockfile like package-lock.json or pnpm-lock.yaml.

# Normal, legitimate cache usage
- uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: Linux-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}

Cache access depends on the branch or ref scope as well as the key. A normal pull_request run saves to its PR merge ref, which a main-branch release cannot restore. A pull_request_target run resolves in the base branch’s context, but GitHub now gives this low-trust trigger read-only access to the default branch cache unless a job or workflow explicitly sets cache-mode: write or write-only. A matching key alone does not cross these boundaries.

This is the current behavior documented in GitHub’s dependency-caching reference. Historical cases below occurred under their then-current workflow and platform conditions. Check the effective ACTIONS_CACHE_MODE and workflow configuration before assuming the same chain still works.


The Attack: Pwn Request + Cache Poisoning

The attack combines two patterns into one chain:

Step 1 — The Pwn Request

pull_request_target runs in the base repository’s context and can access secrets if the workflow grants them. It exists for legitimate tasks such as labeling or commenting on PRs. Checking out and running a fork’s code in that context is dangerous. For the cache-poisoning path described here, the job must also have write-capable cache access; that is no longer the low-trust default.

# Vulnerable pattern — do not use this in production
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
cache-mode: write # Explicitly overrides the low-trust read-only default
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ← attacker's code
- uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: Linux-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm install # ← runs untrusted code before the cache save

This runs attacker-controlled code in the base repository’s context. The explicit cache-mode: write lets actions/cache save in that context after the job; without that override, the current low-trust default prevents this cache write.

Step 2 — Poisoning the Cache

With write access, the attacker’s PR can modify a cached build artifact or dependency and save it under a key the release workflow will restore. The key and branch scope must both line up. Existing immutable cache entries cannot simply be overwritten under the same key; attackers may instead exploit restore-key behavior or cache eviction and recreation.

Attacker's PR workflow runs:
→ pnpm install (with malicious package.json preinstall hook)
→ actions/cache saves ~/.pnpm-store
→ key: Linux-pnpm-store-abc123 ← same key as production

Step 3 — Waiting for Release

The poisoned entry now sits in the shared cache. When a maintainer pushes to main and triggers a release:

Release workflow runs:
→ actions/cache restores Linux-pnpm-store-abc123
→ Restores poisoned store ✓
→ Cached content executes inside the privileged job
→ In TanStack's case, malware used the job's OIDC (OpenID Connect) publishing permission

The attacker does nothing in step 3. The release workflow does it for them.


Real-World Cases

Angular (March 2024) — $31,337 Bug Bounty

Researcher Adnan Khan discovered a four-step chain in the angular/dev-infra repository:

  1. Script injection via ${{ github.head_ref }} in a pull_request_target workflow
  2. Cache filling with the Cacheract tool — stuffed the 10GB repo limit with junk, forcing eviction of legitimate entries
  3. Poisoned node_modules installed under the legitimate cache key
  4. Scheduled ng-renovate workflow restored the poisoned cache, exposing NG_RENOVATE_USER_ACCESS_TOKEN — belonging to angular-robot, a GitHub App with admin access to the Angular repository

Google classified this as a supply chain compromise of a flagship project and awarded $31,337. An attacker could have injected malicious commits into Angular’s production main branch.

tj-actions/changed-files (March 2025)

A different vector, same impact category: attackers compromised the tj-actions/changed-files reusable action itself by injecting a memory-dumping payload. The action was used by over 23,000 downstream workflows. Runs that used the compromised action could expose secrets present in runner memory; that does not establish that every downstream repository leaked credentials.

This was a malicious-action compromise, not evidence that the affected repositories’ caches were poisoned. It illustrates a separate supply-chain boundary: a trusted workflow can run attacker-controlled code through an action it consumes.

Hackerbot-claw (February 2026)

Between February 21–28, 2026, an AI-powered bot systematically scanned public GitHub repositories for exploitable CI/CD workflow patterns and launched automated attacks using five distinct exploitation techniques — including classic Pwn Request and cache poisoning. Targets included repositories belonging to Microsoft, DataDog, and the CNCF (Cloud Native Computing Foundation). This marked the first documented case of autonomous, AI-driven cache poisoning at scale.

TanStack (May 2026) — CVE-2026-45321

The most complete public postmortem to date. An attacker published 84 malicious versions across 42 @tanstack/* npm packages by chaining three vulnerabilities:

  1. pull_request_target workflow with untrusted code checkout
  2. Cache poisoning across the fork↔base trust boundary
  3. Runtime memory extraction of the OIDC token used for npm publishing

TanStack’s updated postmortem says only the Router/Start repository was affected. Query and Form were not affected. The 84 malicious versions across 42 packages were deprecated and removed from npm; TanStack says currently available versions are safe to install.


The Cacheract Tool

The Cacheract PoC (proof-of-concept) demonstrates the persistence mechanism:

  • Downloads the current legitimate cache entry
  • Injects malicious payload (modified package.json, backdoored source files, or custom preinstall hooks)
  • Deletes the original entry
  • Uploads the poisoned version under the original key

The tool also implements cache refresh on trigger — it re-poisons the cache every time a workflow runs, maintaining persistence for as long as the repository is active. GitHub Actions caches expire after 7 days without access; periodic workflow runs reset this timer indefinitely.


How Long Is the Attack Window?

Once an attacker has obtained write-capable access to a cache that a release will restore, the attack can wait for a later privileged run:

PhaseDuration
Attacker opens PRSeconds
Write-capable low-trust workflow poisons cacheMinutes, if the workflow permits it
Cache persistsUntil 7 days after its last access, unless removed earlier
Attacker waits for next releaseDays to weeks

The chain also fails if the low-trust job cannot save to the relevant cache scope, the release does not restore that cache, or its key no longer matches. Restore-key prefixes can broaden what a workflow accepts, so review those too.


Detection

1. Audit pull_request_target Usage

Terminal window
# Find all workflows using pull_request_target
grep -r "pull_request_target" .github/workflows/

Flag any workflow that both: (a) uses pull_request_target, and (b) checks out the PR’s code (ref: ${{ github.event.pull_request.head.sha }}).

2. Check Effective Cache Access

Search workflow and reusable-workflow files for cache-mode: write or write-only, especially under pull_request_target, issue_comment, and workflow_run. Inspect job logs for ACTIONS_CACHE_MODE and cache-save messages. GitHub’s current default is read-only for these low-trust triggers; a configured override deserves review. Do not assume a generic cache.save audit event exists in your export without checking its schema.

3. Monitor Cache Key Collisions

Review cache entries alongside workflow runs, and check whether a low-trust run saved an entry in a scope a release can restore:

Terminal window
# GitHub API — list cache entries, keys and refs
gh api repos/<OWNER>/<REPO>/actions/caches --paginate \
--jq '.actions_caches[] | {key: .key, ref: .ref, created_at: .created_at}'

Run this from an authenticated GitHub CLI session with permission to read Actions cache metadata. The API output shown includes a key and ref, not the workflow that created an entry. Correlate its creation time with workflow logs; a matching key on a PR merge ref does not make that cache restorable from main.

4. Pin Actions by SHA (Commit Hash)

# Vulnerable — tag can be moved
- uses: actions/cache@v4
# Safe — immutable
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684

Tag-based references can be silently updated. SHA (the unique commit identifier) pins guarantee you run exactly what you audited.


What You Can Do Today

Immediate actions:

  1. Audit low-trust workflows — remove untrusted code checkout from privileged jobs and keep cache access read-only. If a reusable workflow is called, explicitly cap the caller with cache-mode: read so the callee cannot request write access.
  2. Pin all external actions to SHA — actions/cache, actions/checkout, and every third-party action
  3. Keep release caches trusted — save them only from trusted triggers and review restore keys. Distinct release- and pr- prefixes add separation but do not replace cache-access controls.
  4. Restrict OIDC token scope — limit which workflows can request publishing tokens; use environment protection rules

For release workflows specifically:

# Disable cache in release workflows entirely
jobs:
release:
runs-on: ubuntu-latest
# No actions/cache step — install fresh every time
steps:
- uses: actions/checkout@<sha>
- run: npm ci --no-cache
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Removing cache restores from a release workflow closes this cache-poisoning path for that workflow, at the cost of a cold dependency install. Measure the time cost in your own pipeline.

Longer term:

  • Enable StepSecurity Harden-Runner for egress filtering on runners
  • Implement SLSA (Supply-chain Levels for Software Artifacts) provenance for all published artifacts
  • Add a CODEOWNERS rule requiring review before any .github/workflows/ change merges


Sources