Cloud Security Office Hours Banner

Mini Shai-Hulud / TanStack npm 2026

Step-by-step kill chain mapped to MITRE ATT&CK Cloud, sourced from official post-mortems and primary technical analyses.

May 2026 Critical npm · GitHub Actions

Mini Shai-Hulud / TanStack - Pwn Request → Actions Cache Poisoning → OIDC Theft from Runner Memory → npm Publish with Valid SLSA Provenance → 150+ Packages Wormed

On May 11, 2026 an attacker published 84 malicious versions across 42 @tanstack/* npm packages inside a six-minute window, then let a self-spreading worm carry the payload outward to 150+ packages under @uipath, @mistralai, @opensearch-project and other namespaces. There was no stolen maintainer token. The entry point was a three-stage GitHub Actions abuse chain: a fork-triggered pull_request_target run poisoned the shared build cache, a later legitimate release run restored that cache inside the privileged pipeline, and a Python payload scraped OIDC tokens straight out of Runner.Worker process memory. Because the packages were then published with tokens minted by the genuine TanStack release workflow, every malicious tarball shipped with a valid SLSA Build Level 3 provenance attestation signed by npm's Sigstore infrastructure. GitHub tracks the TanStack packages as CVE-2026-45321 (CVSS 9.6).

84 versions / 42 packages published in 6 minutes (19:20-19:26 UTC, May 11, 2026)
150+ packages reached by the worm across multiple npm orgs
Valid provenance on every malicious tarball (CVE-2026-45321)
Threat actor: TeamPCP (claimed, not proven)
📄 StepSecurity - full technical teardown ↗ 📄 Socket - SLSA provenance analysis ↗
Initial Access - Pwn Request via pull_request_target
01
A throwaway account forked TanStack/router, renamed the fork to hide it, and opened a PR that ran fork-controlled code in the base repository's context
T1677 - CI/CD Pipeline Abuse

On May 10, 2026 the account voicproducoes forked TanStack/router, then renamed the fork so it would not appear in GitHub's fork listing for the upstream repository (Snyk records it as zblgg/configuration). The attacker then opened a pull request against a workflow using the pull_request_target trigger. That trigger is the whole exploit primitive: unlike pull_request, it runs with the base repository's permissions and secrets while checking out the fork's merge ref, so untrusted, attacker-authored code executed with trusted context on the first PR from a brand-new account. No review, no approval, no maintainer mistake was required.

Attacker account: voicproducoes (created 2026-03-19), fork created 2026-05-10
Malicious commit: 79ac49eedf774dd4b0cfa308722bc463cfe5885c
Why it worked: pull_request_target runs fork code with base-repo permissions and secrets
Evasion: fork renamed so it did not surface in the upstream repo's fork list
Pwn Requestpull_request_targetFork AbuseT1677
🧬 Execution - Poison the Shared Actions Cache, Wait for a Trusted Run
02
The untrusted run wrote a malicious pnpm store into the GitHub Actions cache, which the privileged release workflow later restored and executed
T1036.005 - Match Legitimate Name or Location

This is the step that makes the incident genuinely new. From inside the low-privilege PR run, the attacker wrote a poisoned pnpm store to the repository's shared Actions cache at a pre-computed cache key. The store contained a counterfeit @tanstack/setup package whose package.json carried a prepare lifecycle hook that ran the payload and then exited non-zero to keep the output short. The poisoned cache sat dormant for roughly eight hours. When maintainers merged legitimate PRs to main, the release workflow restored that same cache and ran the attacker's prepare hook inside the privileged pipeline. The Actions cache is a mutable artifact written by untrusted context and read by trusted context, which makes it a privilege boundary that GitHub does not enforce.

Poisoned artifact: pnpm store in the shared Actions cache at a pre-computed key
Payload carrier: fake @tanstack/setup with "prepare": "bun run tanstack_runner.js && exit 1"
Dwell: ~8 hours between cache poisoning and the first trusted restore
Defence gap: Actions caches are not scoped by trust level, so a fork-triggered job can write what a release job reads
Cache PoisoningLifecycle HookTrust BoundaryT1036.005
🔑 Credential Access - Read Secrets Out of Runner Process Memory
03
A Python payload read /proc/{pid}/mem of the Runner.Worker process and harvested every masked secret plus the ambient OIDC token
T1528 - Steal Application Access Token

Running inside the trusted release job, the payload opened /proc/{pid}/mem for the Runner.Worker process and pattern-matched for JSON objects of the form {"value":"...","isSecret":true}. That single read captures every secret the runner knows about, including secrets the job never referenced in a step and secrets that are masked in the log output. Log masking is a display filter, not a confidentiality control: masked values sit in the runner's address space in plaintext. Alongside the secrets, the payload lifted the ambient OIDC material granted by the job's id-token: write permission, specifically the ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL environment variables. That permission is granted at job scope, not step scope, so any code anywhere in the job could mint an identity token for the pipeline.

Method: Python read of /proc/{pid}/mem for the Runner.Worker process
Pattern matched: {"value":"<plaintext>","isSecret":true}
OIDC material: ACTIONS_ID_TOKEN_REQUEST_TOKEN + ACTIONS_ID_TOKEN_REQUEST_URL
Why it worked: id-token: write is job-scoped, and masked secrets are still plaintext in memory
Memory ScrapingOIDC TheftJob-Scoped PermissionsT1528
📦 Impact - Publish Malicious Packages With Valid SLSA Provenance
04
The stolen OIDC token was exchanged for npm publishing credentials outside the workflow's normal publish step, and Sigstore truthfully attested the result
T1195.002 - Compromise Software Supply Chain

The payload posted the stolen OIDC token to registry.npmjs.org/-/npm/v1/oidc/token/exchange to mint per-package publishing credentials, then published outside the workflow's intended publish step. Between 19:20 and 19:26 UTC on May 11, 2026 it pushed 84 versions across 42 @tanstack/* packages, each tarball carrying an injected 2.3 MB router_init.js at the package root plus an optionalDependencies entry pointing at the attacker's fork commit. Because the publish really did originate from the genuine TanStack/router release workflow, npm's Sigstore integration issued a correct SLSA Build Level 3 attestation and the packages wore npm's "verified provenance" badge. Nothing was forged. Provenance answers "which pipeline built this," not "did that pipeline behave," and the pipeline was the attacker. Socket assesses this as the first documented npm supply-chain attack to produce valid SLSA Build Level 3 provenance for malicious artifacts.

Token exchange: registry.npmjs.org/-/npm/v1/oidc/token/exchange for per-package publish creds
Injected file: router_init.js (2.3 MB) at package root, plus optionalDependencies → github:tanstack/router#79ac49ee...
Size tell: compromised tarballs ~3.7x larger than clean ones (905 KB vs 190 KB) with 23 extra files
Why signing did not help: the attestation was accurate; the trusted pipeline itself was executing attacker code
SLSA ProvenanceSigstoreCVE-2026-45321T1195.002
🪱 Propagation - Worm Outward Through Each Maintainer's Packages
05
The payload enumerated every package controlled by each compromised maintainer and republished infected versions with a fresh OIDC exchange per package
T1550.001 - Application Access Token

This is what makes it a worm rather than a one-shot poisoning. On every host where an infected package installed, the payload queried the npm registry for the account's packages and for tokens flagged bypass_2fa: true, then performed a fresh OIDC exchange for each package and published an infected version to it. Each newly infected package became a fresh distribution point, so the campaign expanded without further attacker interaction. Vendor counts differ on the final blast radius because the census was taken at different times: Wiz counted 70+ npm packages plus 2 PyPI packages, Snyk put the total above 170, and @tanstack/react-router alone draws roughly 12 million weekly downloads. Affected namespaces included @uipath (60+ packages), @mistralai, @opensearch-project, and several smaller maintainers.

Enumeration: npm registry API for all packages under each compromised maintainer
2FA bypass: hunted tokens carrying bypass_2fa: true
Per-package auth: a new OIDC exchange for each target, so one stolen identity minted many publish tokens
Reported scope: 70+ (Wiz) to 170+ (Snyk) packages, including @uipath, @mistralai, @opensearch-project
Self-Propagatingnpm Registry APIToken ExchangeT1550.001
🗝 Credential Access - Harvest Everything Reachable From the Install Host
06
On every install the payload swept local credential files and actively queried cloud metadata and secret-manager endpoints
T1552.005 - Cloud Instance Metadata API

Installation triggered a sweep of more than a hundred credential locations: ~/.aws/credentials, ~/.azure/accessTokens.json, ~/.kube/config and the in-cluster service account token, Terraform credentials, ~/.npmrc, ~/.docker/config.json, SSH private keys and ~/.git-credentials, crypto wallets, Signal, Slack, Discord and Telegram configuration, and shell histories. It also collected AI tooling configuration including ~/.claude.json and ~/.claude/mcp.json, which routinely hold API keys for connected services (see MCP risk). Beyond files (T1552.001), it actively queried live credential sources: the full IMDSv2 token flow against 169.254.169.254 for EC2 role credentials, the ECS/Fargate task metadata endpoint at 169.254.170.2 for task role credentials, and a local HashiCorp Vault agent on 127.0.0.1:8200. On a CI runner or a container in a cloud account, that converts a package install into live cloud credentials.

Files (T1552.001): ~/.aws/credentials · ~/.azure/accessTokens.json · ~/.kube/config · ~/.npmrc · ~/.docker/config.json · SSH keys · shell history
AI tooling: ~/.claude.json and ~/.claude/mcp.json
Live endpoints: IMDSv2 at 169.254.169.254 · ECS/Fargate task metadata at 169.254.170.2 · Vault at 127.0.0.1:8200
Why it worked: npm install runs arbitrary code with the full ambient identity of the host it runs on
Credential HarvestIMDSv2VaultT1552.005
🔗 Persistence - Developer Machines and the Repositories Themselves
07
The payload installed OS-level services on developer machines and turned editor and agent config files into executable-on-open surfaces
T1543.001 - Launch Agent

Persistence came in three layers. At the OS level, a gh-token-monitor service was installed as a macOS LaunchAgent at ~/Library/LaunchAgents/com.user.gh-token-monitor.plist or a Linux systemd user unit (T1543.002), re-exfiltrating GitHub tokens continuously across reboots. At the workspace level, a SessionStart hook in .claude/settings.json and a folderOpen task in .vscode/tasks.json re-ran the payload whenever a developer opened the folder or started a Claude Code session, which means simply opening a cloned repository was enough to re-infect. At the repository level, the payload injected .github/workflows/codeql_analysis.yml variants that serialize ${{ toJSON(secrets) }} and POST it to api.masscan.cloud, or upload it as a workflow artifact disguised as formatter output. The security-tooling filename is deliberate camouflage in a diff.

OS service: gh-token-monitor LaunchAgent (macOS) or systemd user unit (Linux, T1543.002)
Open-to-execute: SessionStart hook in .claude/settings.json; folderOpen task in .vscode/tasks.json
Repo persistence: injected .github/workflows/codeql_analysis.yml POSTing ${{ toJSON(secrets) }} to api.masscan.cloud
Camouflage: named after a security scanner so it reads as normal in a workflow diff
LaunchAgentsystemdWorkflow InjectionT1543.001
📤 Exfiltration & Impact - Dual-Channel Egress, Coercion, and Downstream Fallout
08
Stolen data left over an encrypted CDN and a GitHub dead-drop disguised as Dependabot, while a threatening token description discouraged revocation
T1567.001 - Exfiltration to Code Repository

Exfiltration ran on two channels. The first pushed AES-256-GCM ciphertext under an RSA-4096-OAEP wrapped key to the Session Protocol CDN at filev2.getsession.org. The second used GitHub's own GraphQL createCommitOnBranch mutation to commit encrypted blobs into attacker-controlled repositories over ordinary HTTPS to github.com (T1041), authoring the commits as claude@users.noreply.github.com with the message chore: update dependencies and branch names following dependabot/github_actions/format/{dune-word} so the traffic and the history both read like Dependabot. The operator also set the npm token description to IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner, visible in the maintainer's own token dashboard, to deter revocation. Treat that as coercion and sequence your response accordingly: remove the persistence services before rotating tokens. Downstream, OpenAI disclosed on May 14, 2026 that two employee devices were affected. In OpenAI's own characterization there was "unauthorized access and theft of credentials in a limited subset of internal source code repositories," it is rotating code-signing certificates as a precaution, and it has found "no evidence that OpenAI user data was accessed, that our production systems or intellectual property were compromised, or that our software was altered."

Channel 1: filev2.getsession.org (Session Protocol CDN), RSA-4096-OAEP wrapping AES-256-GCM
Channel 2: GitHub GraphQL createCommitOnBranch dead-drop (T1041), commits as claude@users.noreply.github.com
Mimicry: branches named dependabot/github_actions/format/{fremen|sandworm|harkonnen|...}
Coercion: npm token described as IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner
Attribution caveat: TeamPCP is a claim, not a proof; later waves may be copycats
Dead DropDependabot MimicryCoercionT1567.001

🛡 How to Defend Against This Chain

Treat a shared build cache as a privilege boundary, and stop pairing it with pull_request_target. The core failure was architectural: the Actions cache is a mutable artifact written by untrusted context and read by trusted context, so poisoning it converts a fork PR into code execution in the release pipeline. Do not check out fork-controlled code under pull_request_target; use pull_request with required approval for first-time contributors instead. If you must keep both, scope caches by trust level so untrusted runs write to a namespace no privileged workflow ever restores, and never let a release job restore a cache key a PR job can write.
Scope id-token: write to the smallest possible job, and assume masked secrets are readable. The OIDC permission is granted to a whole job, not to the publish step, so every action, test, and lifecycle hook in that job can mint an identity token for your pipeline. Split publishing into a separate hardened workflow that runs no third-party code, does no dependency installs, and holds id-token: write alone. Remember that log masking is a display filter: any secret in the job is plaintext in Runner.Worker memory, so shrink what the job holds rather than relying on redaction.
Verify provenance, but do not stop there. Every malicious tarball here carried an accurate SLSA Build Level 3 attestation and npm's verified-provenance badge, because the build genuinely ran in the legitimate pipeline. Provenance attests which pipeline produced an artifact, not that the pipeline behaved correctly. Pair it with controls that catch the artifact itself: diff tarball contents and size between releases, alert on new optionalDependencies pointing at github: URLs, and enforce a release-age cooldown (Snyk suggests seven days) before new versions reach production builds.
Use npm trusted publishing with environment protection rules and required reviewers. Trusted publishing removes long-lived npm tokens, but on its own it does not stop this chain, because the attacker was inside the trusted workflow. Bind the OIDC subject to a specific workflow file and ref, put the publish job behind a GitHub Environment with required reviewers and a deployment branch policy, and eliminate any token carrying bypass_2fa: true. That turns "compromise the pipeline" into "compromise the pipeline and get a human to approve the release."
Pin every action to a full 40-character commit SHA and pin your own dependencies with a lockfile. Pinning by SHA removes the mutable-tag class of compromise entirely and makes what your pipeline runs auditable. Combine it with npm ci against a reviewed lockfile, and disable install scripts (--ignore-scripts) in CI so a prepare hook in a dependency cannot execute during a build.
Audit .claude/settings.json, .vscode/tasks.json, and .github/workflows/ as executable-on-open surfaces. Cloning a repository is now enough to run code the moment an editor or coding agent opens it. Treat those files as code in review, alert on any commit that adds or modifies them, and scan for workflows referencing toJSON(secrets) or posting to unfamiliar domains. In a compromise, remove the persistence (gh-token-monitor LaunchAgent or systemd unit, plus the editor and agent hooks) before rotating credentials, because the payload ties destructive behavior to token revocation.
Rotate on the assumption that everything the runner touched is burned, and hunt for the specific indicators. Rotate GitHub tokens, npm tokens, AWS keys, Vault tokens, Kubernetes service account tokens, and every other CI/CD secret. Then hunt: python3 processes reading /proc/*/mem during CI runs, egress to filev2.getsession.org, api.masscan.cloud, or git-tanstack.com, commits authored by claude@users.noreply.github.com, and branches matching dependabot/github_actions/format/* that no Dependabot run created.

Related defense topics