A developer opens a pull request to fix a typo. No code review required — the CI/CD (Continuous Integration/Continuous Deployment) pipeline handles it automatically. Six hours later, a malicious npm package with their organization’s name is live and already being pulled into thousands of downstream projects.
This is not hypothetical. It happened to TanStack in May 2026, Angular in 2024, and a growing list of projects in between. The weapon: GitHub Actions cache poisoning.
TL;DR
- GitHub Actions caches are shared between pull request workflows and release workflows — this trust boundary is exploitable
- An attacker opens a PR (pull request), poisons the shared cache via
pull_request_target, then waits for a release to trigger with their payload- Real victims include Angular, TanStack, and the tj-actions incident affecting 23,000+ repositories
- The attack is passive — no timing precision required, just a poisoned cache entry waiting for the next release
- Detection requires monitoring cache writes from PR-triggered workflows and auditing
pull_request_targetusage
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 is particularly dangerous because it exploits features working exactly as designed. There is no CVE (Common Vulnerabilities and Exposures entry) in the traditional sense. The cache does what it’s supposed to do. The problem is architectural: untrusted code from a fork gets to write into shared storage that trusted release workflows later read from.
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') }}The critical design detail: caches are repository-scoped, not workflow-scoped. A cache entry written by a PR-triggered workflow is readable by a release workflow on the main branch — if the cache keys match.
The Attack: Pwn Request + Cache Poisoning
The attack combines two patterns into one chain:
Step 1 — The Pwn Request
pull_request_target is a GitHub Actions trigger designed to run workflows with the base repository’s secrets — even when the PR comes from a fork. It exists for legitimate reasons (e.g., commenting on PRs, posting coverage reports). The problem arises when the workflow also checks out and runs the PR’s code.
# Vulnerable patternon: pull_request_target:
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} # ← attacker's code - run: pnpm install # ← runs in context with cache accessThis runs the attacker’s code in the base repository’s context — including access to write to the shared cache.
Step 2 — Poisoning the Cache
The attacker’s PR modifies a build artifact, dependency, or action file that gets saved into the shared cache under the legitimate key. Because the cache key is based on the lockfile hash, and the attacker’s PR uses the same lockfile as the base branch, the keys match.
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 ✓ → npm publish runs with OIDC (OpenID Connect) token → Malicious package published to registryThe 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. When those workflows ran, the injected code dumped the CI runner’s memory — exposing secrets, API keys, and cloud credentials from every affected repository.
This case illustrates how reusable actions are a second-tier cache poisoning surface: you don’t need to touch the target’s own cache if you can poison an action they consume.
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 Query, Router, and Form — packages with millions of weekly downloads — were all affected before the attack was detected and the versions yanked.
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?
No tight timing is required. The attack is passive by design:
| Phase | Duration |
|---|---|
| Attacker opens PR | Seconds |
pull_request_target workflow poisons cache | Minutes |
| Cache persists | Up to 7 days |
| Attacker waits for next release | Days to weeks |
The only failure condition: a lockfile update between cache poisoning and the next release changes the cache key — causing a cache miss. Attackers can hedge against this by using Cacheract’s restore-key targeting, which matches partial key prefixes.
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. Sigma Rule — Cache Write from PR Workflow
title: GitHub Actions Cache Write from pull_request_targetid: 7b3a2f91-cache-pr-writestatus: experimentaldescription: Detects cache entries written during pull_request_target workflowslogsource: product: github service: audit_logdetection: selection: action: "cache.save" event_name: "pull_request_target" condition: selectionfalsepositives: - Legitimate workflows that intentionally cache PR build artifactslevel: mediumtags: - attack.t1195.0013. Monitor Cache Key Collisions
Alert when a PR-triggered workflow writes a cache key that already exists from a main-branch workflow:
# GitHub API — list cache entries and their workflow originsgh api repos/{owner}/{repo}/actions/caches --paginate \ --jq '.actions_caches[] | {key: .key, ref: .ref, created_at: .created_at}'Cross-reference entries where ref matches a PR branch but the key matches production keys.
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
pull_request_targetworkflows — remove untrusted code checkout or isolate to a separate job without cache access - Pin all external actions to SHA —
actions/cache,actions/checkout, and every third-party action - Separate cache scopes — don’t share cache between PR workflows and release workflows; use distinct key prefixes like
release-vspr- - 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 }}The performance cost of a cold install on release is negligible. The security gain is complete isolation from cache-based attacks.
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
- GATE: Supply Chain Scanner for Python Projects — tooling for detecting dependency-level compromise
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 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