Skip to main content
graphwiz.aigraphwiz.ai
← Back to DevOps

TanStack Supply Chain Attack: Anatomy of a Three-Stage Breach

DevOps
supply-chain-securitynpmgithub-actionsci-cd-hardeningpwn-requestcache-poisoning

On 2026-05-11 between 19:20 and 19:26 UTC, an attacker published 84 malicious versions across 42 @tanstack/* npm packages. The attack required three distinct vulnerabilities, none sufficient alone. TanStack has published a thorough postmortem; this article extracts the technical details every DevOps engineer should be auditing in their own pipelines today.

The Three-Stage Attack Chain

Stage 1: pull_request_target Cache Poisoning

The attacker's entry point was a fork PR against TanStack/router. The malicious PR (#7378) opened from account zblgg exploited the pull_request_target trigger in bundle-size.yml:

on:
  pull_request_target:
    paths: ['packages/**', 'benchmarks/**']

jobs:
  benchmark-pr:
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
      # ↑ Checks out the FORK's merged code — attacker-controlled

      - uses: TanStack/config/.github/setup@main
      # ↑ Transitively calls actions/cache@v5

      - run: pnpm nx run @benchmarks/bundle-size:build
      # ↑ Executes attacker code in the build step

pull_request_target runs with write permissions on the base repository and checks out the PR's merged code. The workflow author intended a trust split between "untrusted" PR code and read-only permissions, but actions/cache@v5's post-job save is not gated by permissions: contents: read. Cache writes use a runner-internal token. The PR's build poisoned the pnpm store under a key the production release.yml workflow would later compute and restore.

When release.yml ran on a push to main, it restored the poisoned cache — attacker-controlled binaries now on disk inside the release workflow's context.

Stage 2: OIDC Token Memory Extraction

release.yml declared id-token: write legitimately for npm OIDC trusted publishing. When the poisoned pnpm store was restored, those binaries were invoked during the build step. They then:

  1. Located the GitHub Actions Runner.Worker process via /proc/*/cmdline
  2. Read /proc/<pid>/maps and /proc/<pid>/mem to dump the worker's memory
  3. Extracted the OIDC token (minted lazily in memory when id-token: write is set)
  4. Used the token to POST directly to registry.npmjs.org — bypassing the workflow's Publish Packages step entirely

This is the same memory-extraction technique and verbatim Python script used in the tj-actions/changed-files compromise of March 2025. No novel tradecraft — recombination of published research.

Stage 3: Self-Propagation

The malware's prepare lifecycle script also enumerated other packages the victim maintains via registry.npmjs.org/-/v1/search?text=maintainer:<user> and republished them with the same injection. This is a worm — not just credential theft, but lateral spread through the victim's own package portfolio.

Impact

MetricValue
Packages affected42 @tanstack/* packages
Versions published84 (two per package, 6 minutes apart)
DetectionExternal researcher (ashishkurmi, StepSecurity), ~20 minutes post-publish
No npm tokens stolenAttack used OIDC token minted from inside the workflow
Self-propagationMalware enumerated and republished victim's other packages

The payload installed a dead-man's switch: ~/.local/bin/gh-token-monitor.sh as a systemd user service (Linux) or LaunchAgent (macOS). It polled api.github.com/user every 60 seconds. If the stolen token was revoked while the infected machine was still online, the script ran rm -rf ~/.

Timeline

Time (UTC)Event
2026-05-10 17:16Attacker forks TanStack/router as zblgg/configuration
2026-05-10 23:29Malicious commit 65bf499d added to fork (30,000-line bundled payload)
2026-05-11 10:49PR #7378 opened; pull_request_target workflows auto-trigger
2026-05-11 11:11Force-push delivers payload commit via PR head
2026-05-11 11:29Poisoned cache entry saved: Linux-pnpm-store-6f9233a50...
2026-05-11 11:31PR force-pushed back to main HEAD, then closed — cache poison persists
2026-05-11 19:15Legitimate PR #7369 merges → release.yml triggers
2026-05-11 19:20:39First batch of 42 packages published via stolen OIDC token
2026-05-11 19:20:47Release workflow run fails (malicious payload broke tests)
2026-05-11 ~19:50External researcher opens issue with full IOC analysis
2026-05-11 21:00All 84 affected versions deprecated; caches purged
2026-05-11 21:30Hardening PR merged; GitHub Security Advisory published

Detection IOCs

For security teams scanning downstream:

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
  • File in affected tarball: router_init.js (~2.3 MB, package root, not in "files")
  • Cache key: Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11
  • Exfiltration network: filev2.getsession.org, seed{1,2,3}.getsession.org
  • Forged commit identity: claude <claude@users.noreply.github.com> (fabricated, not Anthropic)
  • Attacker accounts: zblgg (GitHub ID 127806521), voicproducoes

What the Postmortem Gets Right

TanStack's analysis is notable for its candour:

  • No internal alerting detected the compromise — third-party researcher reported first
  • pull_request_target workflows had never been audited despite being a long-known dangerous pattern
  • Floating action refs (@v6.0.2, @main) create standing supply-chain risk independent of this incident
  • OIDC trusted-publisher binding has no per-publish review: once configured, any code path in the workflow can mint a publish-capable token
  • npm's "no unpublish if dependents exist" policy prevented direct removal; had to rely on npm security pulling tarballs server-side

The attacker also got lucky: their payload broke tests, which caused the publish step (which would have produced cleaner-looking tarballs) to skip. A more careful attacker could have published silently for hours.

Hardening Checklist

If you run pull_request_target workflows, audit for these specific conditions:

pull_request_target

# NEVER do this — checkout of fork PR head inside a workflow with write permissions
- uses: actions/checkout@v6.0.2
  with:
    ref: refs/pull/${{ github.event.pull_request.number }}/merge

# Instead: use github.event.pull_request.head.repo.full_name to detect cross-repo
# Or: use a separate "untrusted" job that has NO write permissions and NO cache access

GitHub Actions Cache

  • Cache scope is per-repo, shared across pull_request_target and main branch workflows
  • actions/cache@v5 post-job save is not gated by permissions: contents: read
  • Mitigation: use separate cache keys per trigger context, or disable cache restore entirely for untrusted triggers
- uses: actions/cache@v5
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  # ↑ Only save cache for trusted contexts

OIDC Trusted Publishing

  • id-token: write mints a token lazily in runner memory on first use
  • Any attacker code running on the runner can extract it
  • Mitigation: GitHub Environments with manual approval gates for publish workflows
  • Further mitigation: require manual approval on npm registry side (not yet standard practice)
environment: publish  # Configure environment protection rules in GitHub settings
permissions:
  id-token: write
  contents: read

Broader Implications

The security community has documented pull_request_target cache poisoning since 2024 (Adnan Khan, "The Monsters in Your Build Cache"). This attack demonstrates that known, documented patterns are being actively exploited at scale against high-profile open-source projects.

The chain demonstrates that OIDC trusted publishing, while an improvement over static tokens, does not prevent a compromised CI pipeline from publishing malicious packages. The second factor — npm's side — remains the gap: once an OIDC token is minted from inside a workflow, any code in that workflow can use it to publish.

The self-propagating design (enumerating and republishing the victim's other packages) represents an escalation from pure supply-chain attack to automated lateral movement. This is no longer just about stealing credentials — it's about turning your package publication infrastructure into a malware distribution network.


References