TanStack Npm Packages Compromised Inside The Mini Shai Hulud Supply Chain Attack
11 de maio de 2026
0 minutos de leituraTanStack npm packages compromised: inside the Mini Shai-Hulud supply chain attack
On May 11, 2026, between 19:20 and 19:26 UTC, 84 malicious npm package artifacts were published across 42 packages in the @tanstack namespace. The packages were not published by an attacker who stole credentials; they were published by TanStack's legitimate release pipeline, using its trusted OIDC identity, after attacker-controlled code hijacked the runner mid-workflow. The malicious versions spread to Mistral AI, UiPath, and dozens of other maintainers within hours. @tanstack/react-router alone receives over 12.7 million weekly downloads (npm API).
The incident is attributed by StepSecurity to the threat group known as TeamPCP, and it is also the first documented case of a malicious npm package carrying valid SLSA provenance. SLSA provenance is a cryptographic certificate, generated by Sigstore, that is meant to verify a package was built from a trusted source. The worm was able to produce these certificates because it hijacked the legitimate build pipeline itself; Sigstore verified the build process correctly. What SLSA does not guarantee is that the code being built was safe.
If your team installed any affected @tanstack/* version on May 11, treat the install environment as compromised and rotate every secret accessible from that host. Keep reading for the full package list, technical breakdown, and step-by-step remediation.
CVE: CVE-2026-45321 | GHSA: GHSA-g7cv-rxg3-hmpx | Severity: Critical
Wave four: campaign context
The TanStack attack is not an isolated incident. It is the latest wave in a series of npm supply chain attacks using the Shai-Hulud worm toolchain. TeamPCP, the group StepSecurity attributes this attack to, is also responsible for compromising Aqua Security's Trivy scanner (March 2026), the Bitwarden CLI npm package (April 2026). The earlier Shai-Hulud waves (September and November 2025) used the same worm toolchain but were not attributed to TeamPCP.
Wave | Date | Scale | Key escalation |
|---|---|---|---|
Sep 14–16, 2025 | 500+ packages, 700+ repos | First self-propagating npm worm; TruffleHog for secrets | |
Nov 21–23, 2025 | 492 packages, 132M monthly downloads, 25,000+ repos |
| |
Apr 29, 2026 | First AI coding agent persistence ( | ||
May 11, 2026 | 373 malicious versions, 169 packages | First npm worm with valid SLSA Build Level 3 attestations; Session P2P C2 |
Each wave builds on the previous wave's technical sophistication. Wave four is notable not for its scale (Wave 2 was larger) but for what it achieved: publishing malicious packages that are indistinguishable from legitimate ones by provenance attestation, a property no prior supply chain attack had demonstrated.
TeamPCP publicly took credit for the attack. The group is also tracked under the aliases DeadCatx3, PCPcat, ShellForce, and CipherForce. Unit 42 has documented the group's announced partnership with the Vect ransomware group, based on a BreachForums announcement.
CISA issued advisories for both the original September 2025 wave and the tj-actions/changed-files compromise (March 2025), which first documented the OIDC token-extraction technique reused in this attack.
What got hit
The core TanStack router family was the initial vector, but the worm's self-propagation mechanism rapidly expanded the blast radius.
TanStack packages (42 packages, 84 versions — two per package):
Package | Compromised versions |
|---|---|
1.169.5, 1.169.8 | |
1.169.5, 1.169.8 | |
1.169.5, 1.169.8 | |
1.169.5, 1.169.8 | |
1.167.68, 1.167.71 | |
1.167.38, 1.167.41 |
The full 42-package list is in GHSA-g7cv-rxg3-hmpx and Snyk's Security Database. Confirmed-clean families: @tanstack/query*, @tanstack/table*, @tanstack/form*, @tanstack/virtual*, @tanstack/store.
Secondary victims (worm-propagated):
Namespace | Example packages |
|---|---|
| |
| 40+ packages across the UiPath namespace |
|
|
| 19 aviation data packages |
misc. |
|
By end of day, at least 170 affected packages had been documented in Snyk's Security Database. @mistralai/mistralai is also registered in the OSSF malicious packages database as MAL-2026-3432 (GHSA-3q49-cfcf-g5fm).
How the attack worked: three chained vulnerabilities
The TanStack postmortem is thorough. Three vulnerabilities were chained; none alone would have been sufficient.
Step 1: Pwn Request via pull_request_target
On May 10, 2026, the attacker created a fork of TanStack/router under the account zblgg (GitHub ID 127806521), deliberately naming it zblgg/configuration to avoid appearing in fork-list searches. A malicious commit (65bf499d) was authored under the fabricated identity claude <claude@users.noreply.github.com> impersonating the Anthropic Claude GitHub App, and prefixed with [skip ci] to suppress automated CI on push.
On May 11 at 10:49, the attacker opened PR #7378 against TanStack/router#main, titled "WIP: simplify history build," and triggered a known misconfiguration: TanStack's bundle-size.yml workflow used the pull_request_target trigger but checked out the fork's merge ref and executed fork-controlled code:
This "Pwn Request" pattern was first documented and demonstrated against Angular, MDN, and hyperledger/besu by security researcher Adnan Khan in May 2024. The pull_request_target trigger runs in the base repo's security context, so the fork's code had access to the base repo's cache scope and GITHUB_TOKEN.
Step 2: GitHub Actions cache poisoning
The malicious vite_setup.mjs from the fork did not exfiltrate data immediately. Instead, it poisoned the pnpm package store under the exact cache key that release.yml would later look up:
This key was pre-computed from the public pnpm-lock.yaml using the same hashFiles('**/pnpm-lock.yaml') formula the workflow uses. The poisoned 1.1 GB cache entry was saved at 11:29 and persisted undetected for nearly eight hours, until a legitimate main branch push triggered release.yml at 19:15.
The author of bundle-size.yml had attempted a trust split — keeping the benchmark job separate with a note about untrusted permissions — but missed a critical GitHub Actions behavior: actions/cache@v5's post-job save uses a runner-internal token, not the workflow GITHUB_TOKEN, so permissions: contents: read does not prevent cache writes. Cache scope is also shared across pull_request_target runs and base-branch pushes, creating the cross-boundary poisoning window.
Step 3: OIDC token extraction from runner memory
release.yml holds id-token: write permission, which is required for npm's OIDC trusted-publisher binding. When attacker-controlled binaries from the poisoned pnpm store ran during the build phase, they used a technique documented in the tj-actions/changed-files compromise of March 2025; the TanStack postmortem notes the attacker used "the same memory-extraction technique (and verbatim Python script, with attribution comment)" from that incident:
Locate the
Runner.Workerprocess via/proc/*/cmdlineRead
/proc/<pid>/mapsand/proc/<pid>/memto dump the worker's address spaceExtract the OIDC token, which the runner mints lazily in memory when
id-token: writeis setPOST directly to
registry.npmjs.orgauthenticated as the legitimate TanStack release workflow
The workflow's intended Publish Packages step was never reached, tests failed, and the step was skipped. The malicious publish happened anyway, authenticated with a valid OIDC token and valid SLSA provenance, because the attacker extracted the token before the workflow finished.
Two release runs occurred. Both completed with status: failure. npm received 84 valid, signed, provenance-attested package publishes, run 25613093674 and 25691781302.
Inside the payload: router_init.js
The 2.3 MB router_init.js file was smuggled into each compromised tarball's root, a file not declared in the package's files field, which proves the tarball was tampered with outside the normal build process. Each package also received an injected optionalDependencies entry:
The commit hash refers to an orphan commit in the attacker's fork that GitHub surfaces through the legitimate TanStack/router URL due to shared commit object storage across fork networks. The URL looks official; the commit is not. When npm resolves the dependency, it fetches and runs a prepare lifecycle hook:
The && exit 1 is deliberate: the optional dependency "fails" gracefully, leaving almost no trace in install logs while the payload is already executed in the background.
One important defensive note: Bun, as a package manager, does not execute lifecycle scripts by default, so developers installing the affected packages via Bun directly would not have triggered this particular payload delivery vector. Machines with Bun already installed also trigger an early-exit in the payload's hasCommand("bun") check. Neither makes Bun a complete mitigation, the router_init.js file is still present in the tarball, but it does reduce the install-time attack surface.
Three layers of obfuscation
router_init.js uses a three-layer protection scheme documented by Upwind Security's full deobfuscation (using webcrack 2.16.0, which produced 221,771 lines of readable JavaScript from the 11.7 MB obfuscated blob):
Layer 1: The canonical JavaScript Obfuscator pattern — a self-invoking string-array rotation bootstrap followed by a dispatcher function (_0x253b) called 2,864 times on a single source line. All string literals are replaced by array lookups.
Layer 2: A per-byte Fisher-Yates substitution cipher. The key is derived via PBKDF2-SHA256 with hardcoded master key 0c0e873033875f1bc471eda37e3b9d0f9b89bd41a4bbb4f86746caa2186c40aa and salt svksjrhjkcejg at 200,000 iterations — deliberately slow to resist automated analysis. This layer decrypts 396 unique string constants including C2 domains, credential paths, and the campaign's internal name: EveryBoiWeBuildIsAWormyBoi.
Layer 3: 11 AES-256-GCM encrypted payloads, gzip-compressed, requiring the Bun runtime (Bun.gunzipSync) to decrypt. The encryption function, recovered from deobfuscation:
The same ctf-scramble-v2 Fisher-Yates PRNG (seeded with 0x3039 / 12345) appears verbatim across the Bitwarden CLI, SAP, and TanStack waves — Unit 42 identified this as a shared-authorship indicator connecting all three attacks to the same codebase.
Daemonization
Before doing anything visible, the payload checks process.env.__DAEMONIZED. If unset, it forks a fully detached child with all stdio suppressed and calls unref() so Node.js does not wait for the child. The parent exits cleanly; the child runs silently and is decoupled from the terminal session that ran npm install.
Credential harvesting
The payload performs a systematic sweep of every major credential plane in cloud-native CI environments. Note that the runner memory scraper deliberately skips tokens explicitly named github_token — likely to avoid triggering GitHub's own secret-scanning on the exfiltrated data.
GitHub Actions:
Direct reads:
GITHUB_REPOSITORY,GITHUB_SERVER_URL,ACTIONS_ID_TOKEN_REQUEST_TOKEN,ACTIONS_ID_TOKEN_REQUEST_URLGitHub REST API:
GET /repos/<repo>/actions/secrets?per_page=100(the API maximum)
AWS:
Environment variables:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,AWS_ROLE_ARN,AWS_WEB_IDENTITY_TOKEN_FILEIMDSv2 (correctly implemented — will succeed against hardened instances that have disabled IMDSv1)
ECS Task Metadata endpoint (
169.254.170.2)Multi-region Secrets Manager and SSM Parameter Store enumeration
HashiCorp Vault:
VAULT_TOKEN,VAULT_ADDR,VAULT_AUTH_TOKENDirect API calls to
vault.svc.cluster.local:8200(the Kubernetes-internal Vault endpoint)
Kubernetes:
/var/run/secrets/kubernetes.io/serviceaccount/service account tokensCombined with the Vault endpoint: steal K8s token → authenticate to Vault → retrieve all Vault secrets
Workstation targets:
~/.npmrc,~/.git-credentials, SSH private keys under~/.ssh/GCP metadata, cloud provider credential files across 100+ paths on Linux and macOS
~/.claude/projects/*.jsonl— Claude Code session history files containing code, commands, and potentially credentials that appeared in prior Claude Code sessions
Token regex patterns recovered from deobfuscation:
Exfiltration and dead drops
Stolen data is encrypted and sent through the Session/Oxen decentralized P2P messenger network (filev2.getsession[.]org, seed{1,2,3}.getsession.org). There is no attacker-controlled C2 server; traffic is routed through Session's onion-based service nodes and is indistinguishable from encrypted messaging app telemetry. IP-level blocking is ineffective; DNS-level blocking of *.getsession.org is the practical perimeter control.
Secondary exfiltration routes through GitHub's own GraphQL API via dead-drop commits: authored as claude@users.noreply.github.com (impersonating the Anthropic Claude GitHub App), message chore: update dependencies, with Dependabot-style branch names drawn from Frank Herbert's Dune universe, dependabout/…/setup-formatter (note: "dependabout," not "dependabot"). GitHub's GraphQL endpoint is allowlisted in virtually every CI network egress policy, so this channel bypasses perimeter controls entirely.
The payload also injects .github/workflows/codeql_analysis.yml into every accessible repository, exfiltrating toJSON(secrets) on push — harvesting the full GitHub Actions secrets context of victim organizations.
A fallback recovery mechanism searches GitHub for commits containing the magic string OhNoWhatsGoingOnWithGitHub to recover tokens previously exfiltrated by other victims.
Self-propagation
After exfiltrating credentials, the worm queries:
It then republishes each package under the stolen OIDC identity, with the same router_init.js injection and the same optionalDependencies entry. Propagation extended to over 200 packages beyond the initial 84 @tanstack/* versions, as documented in Snyk's Security Database. This is what spread the worm to Mistral AI, UiPath, and the dozens of other secondary victims.
Crucially, the worm uses generateKeyPairSync and sign to forge Sigstore-compatible in-toto provenance attestations for every package it republishes. This means secondary victims' packages also carry what appears to be valid SLSA provenance; the SLSA attestation failure mode extends to worm-propagated packages, not just the initial TanStack publish.
Persistence: uninstall is not enough
The worm writes copies of itself into developer tooling directories to survive across npm uninstall and reboots:
Claude Code hooks (.claude/):
Claude Code's hooks configuration can execute shell commands in response to tool events such as file edits and bash runs. By writing itself into .claude/settings.json, the worm re-executes every time a developer uses Claude Code in the affected project directory.
VS Code (.vscode/):
VS Code's workspace task auto-run provides an independent execution vector. Together, these two mechanisms mean a developer who ran npm install on an affected version may be re-compromising their environment every time they open their editor.

Nx npm Malware Explained: AI Agent Hijacking — How supply chain malware abuses VS Code tasks and AI coding agent hooks as persistence vectors, with analysis of the Nx npm worm campaign that used identical techniques.
System-level dead-man's switch:
This service polls api.github.com/user with the stolen GitHub token every 60 seconds. If the token is revoked (HTTP 40x response), it runs rm -rf ~/ — destroying the user's home directory.
Remediation order matters: disable the monitor service before revoking credentials. If you revoke first, you may trigger home directory destruction. This was independently identified by researcher carlini in GitHub issue comment #4425225340 and surfaced by the HN community within hours of the attack.
The SLSA provenance problem
This attack is the first documented case of an npm worm producing validly-attested SLSA Build Level 3 provenance for malicious packages. The Sigstore attestations on the compromised @tanstack/* versions are legitimate: they correctly attest that the packages were built and published by release.yml running on refs/heads/main in the TanStack/router repository. That is all true.
SLSA provenance attests that a package was built by a specific repository's GitHub Actions run. It does not attest that the workflow was authorized to run, that it executed from a protected branch, or that the commit triggering it was legitimate.
The exploitable OIDC configuration:
The secure configuration:
Any npm package using OIDC trusted publishing without branch and workflow pinning is vulnerable to this class of attack. Provenance attestations remain a necessary piece of supply chain security — they are not a sufficient one. Behavioral analysis at install time is the complementary control. Automated behavioral analysis flagged all 84 affected artifacts within six minutes of publication by detecting anomalies in router_init.js before any human analyst reviewed the packages.
What to do right now
Step 0: Determine if you are exposed
Check whether any affected version reached your environments without executing scripts:
Step 1: Contain persistence BEFORE rotating credentials
If you find evidence of compromise, disable the dead-man's switch before doing anything else:
Then remove editor persistence hooks:
Step 2: Rotate all secrets
After persistence is removed, rotate in this priority order:
npm publish tokens and OIDC federation grants for any npm package published from affected repos
GitHub PATs and fine-grained personal access tokens
AWS credentials (both static keys and instance role trusts via IMDSv2)
HashiCorp Vault tokens
Kubernetes service account tokens
SSH private keys
GCP service account credentials
Any secrets visible in
~/.claude/projects/*.jsonl— Claude Code session logs are a harvest target
Re-establish OIDC federation grants only after confirming the publishing workflow is clean.
Step 3: Network-level mitigations
Block *.getsession.org at the DNS level. IP-based blocking is insufficient — the Session network uses distributed service nodes. DNS-level blocking is the practical perimeter control.
Also block: api.masscan.cloud and git-tanstack.com (additional C2 domains from the campaign infrastructure). Second-stage payload URLs to block at egress: litter.catbox.moe/h8nc9u.js and litter.catbox.moe/7rrc6l.mjs.
Step 4: Audit GitHub Actions OIDC configuration
For any npm package using trusted publishing, pin the OIDC configuration to a specific branch and workflow file:
Set permissions: id-token: none at the workflow level, granting id-token: write only in the specific job that publishes:
Step 5: Audit pull_request_target workflows
Any workflow using pull_request_target that also checks out fork code and writes to the cache is vulnerable to the cache poisoning vector. Either use pull_request (fork context, read-only) or separate fork code execution from base-repo cache writes entirely.
Purge existing GitHub Actions caches for any repo with pull_request_target + cache writes:
Pin all third-party action references to commit SHAs, not tags:
Step 6: Add release-age cooldowns
The malicious versions were live for approximately three hours. A seven-day cooldown would have fully protected against this specific attack (see also: pnpm supply chain hardening settings):
Also consider allow-git=none for npm v11+ to prevent git-URL dependencies from installing (the attack vector for the @tanstack/setup optionalDependency).
Step 7: Do not trust SLSA provenance alone
This attack produces valid SLSA Build Level 3 attestations for malicious packages. Provenance verification is necessary; it is not sufficient. Combine it with behavioral analysis at install time and a tool that checks packages against known malicious signatures.
Snyk's coverage
Snyk's Security Database covers the related LiteLLM PyPI supply chain attack (SNYK-PYTHON-LITELLM-15762713), where tampered versions of litellm were uploaded directly to PyPI, installing a multi-stage credential stealer and persistent backdoor using the same .pth file technique.
Snyk's in-depth analysis of the LiteLLM attack is available at snyk.io/articles/poisoned-security-scanner-backdooring-litellm.
Snyk Open Source scans your dependency tree against the Snyk Security Database and flags known-compromised package versions. If you are not already scanning your projects, try Snyk for free to check your current exposure.

Shai-Hulud NPM Attack: Remediation with Snyk — Walkthrough of using Snyk to identify compromised packages in your dependency tree and remediate exposure.
Summary: indicators of compromise
Indicator | Value |
|---|---|
CVE | CVE-2026-45321 |
GHSA | GHSA-g7cv-rxg3-hmpx |
Malicious file |
|
SHA-256 ( |
|
SHA-256 ( |
|
Malicious |
|
Attacker fork | |
Malicious orphan commit |
|
Primary exfil domain |
|
Additional C2 |
|
Second-stage payloads |
|
Dead-drop commit author |
|
Dead-drop branch pattern |
|
Persistence files |
|
Dead-man's switch (Linux) |
|
Dead-man's switch (macOS) |
|
Campaign PBKDF2 salt |
|
Campaign string |
|
Community-maintained detection scripts: GLPMC/Tanstack-Worm-Detector and omarpr/mini-shai-hulud-ioc-scanner (please do verify before using these, though).
Start securing your Python apps
Find and fix Python vulnerabilities with Snyk for free.
No credit card required.
Or Sign up with Azure AD Docker ID Bitbucket
By using Snyk, you agree to abide by our policies, including our Terms of Service and Privacy Policy.
