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).
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.
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
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.
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
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.
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
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.
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
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.
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
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.
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
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.
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
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 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
🛡 How to Defend Against This Chain
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.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.optionalDependencies pointing at github: URLs, and enforce a release-age cooldown (Snyk suggests seven days) before new versions reach production builds.bypass_2fa: true. That turns "compromise the pipeline" into "compromise the pipeline and get a human to approve the release."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..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.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.