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_targetcode execution,cache-modeoverrides, 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 productionon: 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 saveThis 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 productionStep 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 permissionThe 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:
- Script injection via
${{ github.head_ref }}in apull_request_targetworkflow - Cache filling with the Cacheract tool — stuffed the 10GB repo limit with junk, forcing eviction of legitimate entries
- Poisoned node_modules installed under the legitimate cache key
- Scheduled
ng-renovateworkflow restored the poisoned cache, exposingNG_RENOVATE_USER_ACCESS_TOKEN— belonging toangular-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:
pull_request_targetworkflow with untrusted code checkout- Cache poisoning across the fork↔base trust boundary
- 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:
| Phase | Duration |
|---|---|
| Attacker opens PR | Seconds |
| Write-capable low-trust workflow poisons cache | Minutes, if the workflow permits it |
| Cache persists | Until 7 days after its last access, unless removed earlier |
| Attacker waits for next release | Days 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
# Find all workflows using pull_request_targetgrep -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:
# GitHub API — list cache entries, keys and refsgh 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@5a3ec84eff668545956fd18022155c47e93e2684Tag-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:
- 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: readso the callee cannot request write access. - Pin all external actions to SHA —
actions/cache,actions/checkout, and every third-party action - Keep release caches trusted — save them only from trusted triggers and review restore keys. Distinct
release-andpr-prefixes add separation but do not replace cache-access controls. - Restrict OIDC token scope — limit which workflows can request publishing tokens; use environment protection rules
For release workflows specifically:
# Disable cache in release workflows entirelyjobs: 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
CODEOWNERSrule requiring review before any.github/workflows/change merges
Related Posts
- The Build Is the Target: CI/CD Pipeline Attacks and How to Detect Them — broader CI/CD attack coverage including Jenkins, ArgoCD, and secrets leakage
- npm Supply Chain Attack: The Axios/TeamCP Incident — downstream impact when poisoned packages reach registries
Sources
- The Monsters in Your Build Cache — Adnan Khan
- Turning Almost Nothing into a Supply Chain Compromise of Angular — Adnan Khan
- Postmortem: TanStack npm supply-chain compromise
- GitHub Docs: Dependency caching, access restrictions and cache-mode
- GitHub Action tj-actions/changed-files supply chain attack — Wiz
- GitHub Actions Supply Chain Attack — Palo Alto Unit 42
- Mitigating Attack Vectors in GitHub Workflows — OpenSSF
- Keeping Your GitHub Actions Secure Part 4 — GitHub Security Lab
- ActionsCacheBlasting PoC — GitHub
- Cacheract PoC — GitHub
- CI/CD Incidents — StepSecurity
Useful read?
Find us again on Google.
Add Hive Security as a preferred source for practical security research and analysis.
Add as a preferred source on GoogleChoose Hive Security in Google's source preferences.