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_target usage

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 pattern
on:
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 access

This 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 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 ✓
→ npm publish runs with OIDC (OpenID Connect) token
→ Malicious package published to registry

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. 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:

  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 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:

PhaseDuration
Attacker opens PRSeconds
pull_request_target workflow poisons cacheMinutes
Cache persistsUp to 7 days
Attacker waits for next releaseDays 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

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. Sigma Rule — Cache Write from PR Workflow

title: GitHub Actions Cache Write from pull_request_target
id: 7b3a2f91-cache-pr-write
status: experimental
description: Detects cache entries written during pull_request_target workflows
logsource:
product: github
service: audit_log
detection:
selection:
action: "cache.save"
event_name: "pull_request_target"
condition: selection
falsepositives:
- Legitimate workflows that intentionally cache PR build artifacts
level: medium
tags:
- attack.t1195.001

3. Monitor Cache Key Collisions

Alert when a PR-triggered workflow writes a cache key that already exists from a main-branch workflow:

Terminal window
# GitHub API — list cache entries and their workflow origins
gh 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@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 pull_request_target workflows — remove untrusted code checkout or isolate to a separate job without cache access
  2. Pin all external actions to SHAactions/cache, actions/checkout, and every third-party action
  3. Separate cache scopes — don’t share cache between PR workflows and release workflows; use distinct key prefixes like release- vs pr-
  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 }}

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 CODEOWNERS rule requiring review before any .github/workflows/ change merges


Sources