BACK

Real World Attacks

Shai Hulud hits over 100 NPM packages

Andy Blair

May 12, 2026

Real World Attacks

Shai Hulud hits over 100 NPM packages

Andy Blair

May 12, 2026

Real World Attacks

Shai Hulud hits over 100 NPM packages

Andy Blair

May 12, 2026

No headings found in content selector: .toc-content

TeamPCP continues to target high-privilege tooling in the open-source supply chain. The latest incident is a Shai Hulud–style worm that has now hit over 100 npm packages, propagating through compromised CI/CD pipelines.

Initial access: tanstack/router

The entry point was a malicious pull request opened against tanstack/router (PR #7378) from a fork at github.com/zblgg/configuration. The PR succeeded because the repository had GitHub Actions' pull_request_target trigger enabled, which runs workflows with write-scoped tokens against code from untrusted forks.

The clever bit: poisoning the PNPM cache

The malicious workflow itself contained no secrets and exfiltrated nothing directly. Instead, it used its run to write into the shared GitHub Actions cache under the key:

This is the same cache key that the project's legitimate release.yml workflow uses. When release.yml later ran on a trusted branch, it restored the poisoned PNPM store, executing the attacker's code inside a workflow that had id-token: write permissions.

Token theft via /proc

Once running inside release.yml, the payload located the publishing process's PID and read:

  • /proc/<pid>/maps

  • /proc/<pid>/mem

to scrape the raw OIDC token out of process memory. That token is what npm trusts for trusted-publisher publishing, so it gave the attackers the ability to push malicious versions of any package the project publishes; which is how the worm has now spread to 100+ packages.

Why this matters

  • pull_request_target + shared caches is a known-dangerous combination. A workflow that looks read-only can still write to the cache and poison later privileged runs.

  • Secrets in env vars aren't the only thing to protect; process memory of any workflow with id-token: write is now a target.

  • Trusted publishing does not eliminate supply-chain risk; it just moves it from long-lived tokens to short-lived ones that can still be stolen mid-flight.

Payload analysis: router_init.js

The malicious file injected into @tanstack/react-router is router_init.js — a 2.3 MB single-line Bun-compiled JS bundle. It is never listed in the package's files field in package.json, making it easy to miss in a diff. The bundle has multiple obfuscation layers, anti-analysis checks, six credential collectors, dual exfiltration channels, and a self-replication mechanism.

Obfuscation

Two layers protect the payload from static analysis.

Layer 1 — string-array rotation (obfuscator.io style). Every string literal is replaced with a call to a lookup function backed by a shuffled array. A self-executing IIFE rotates the array to the correct offset at runtime using an arithmetic checksum; if you try to extract strings without first running the rotation, every index is wrong:




Layer 2 — beautify() AES decryption. A second function decrypts base64-encoded ciphertext at runtime. Every sensitive string — URLs, environment variable names, file paths, and the embedded Python payload — is hidden behind this call:

// These decode at runtime to e.g. "LANG" and "GITHUB_ACTIONS"
process.env[beautify('rX54ou2uVvizjlyyIxhohB/m')]
process.env[beautify('+iyuB+mQ6ERjSXrNsBKe6lUkpR2w6O20vmE=')]
// These decode at runtime to e.g. "LANG" and "GITHUB_ACTIONS"
process.env[beautify('rX54ou2uVvizjlyyIxhohB/m')]
process.env[beautify('+iyuB+mQ6ERjSXrNsBKe6lUkpR2w6O20vmE=')]
// These decode at runtime to e.g. "LANG" and "GITHUB_ACTIONS"
process.env[beautify('rX54ou2uVvizjlyyIxhohB/m')]
process.env[beautify('+iyuB+mQ6ERjSXrNsBKe6lUkpR2w6O20vmE=')]

Anti-analysis checks

The main orchestrator runPayload() calls preflightChecks() first and exits silently if either check fires.

Russia/CIS locale check (isRussianLocale()). If the system locale or any language environment variable contains "ru", the payload exits. This is a standard for teamPCP attacks:




Sandbox/scanner detection (isSecurityScannerEnv()). Checks 16+ environment variables to detect AV sandboxes and security analysis pipelines. Variable names are all AES-encrypted in the bundle:

function isSecurityScannerEnv() {
  // each beautify() call decodes to a known sandbox/scanner env var name
  if (process.env[beautify(SCANNER_VAR_1)]) return true;
  if (process.env[beautify(SCANNER_VAR_2)]

function isSecurityScannerEnv() {
  // each beautify() call decodes to a known sandbox/scanner env var name
  if (process.env[beautify(SCANNER_VAR_1)]) return true;
  if (process.env[beautify(SCANNER_VAR_2)]

function isSecurityScannerEnv() {
  // each beautify() call decodes to a known sandbox/scanner env var name
  if (process.env[beautify(SCANNER_VAR_1)]) return true;
  if (process.env[beautify(SCANNER_VAR_2)]

The payload also suppresses SIGTERM and SIGINT so it cannot be killed cleanly while exfiltration is in progress:




Credential collectors

collectAllCredentials() runs three collectors in parallel (FilesystemCollector, ShellEnvCollector, GitHubActionsCollector), then five cloud-service scanners stream their results through the same dispatcher.

FilesystemCollector : uses Bun.Glob to scan OS-specific "hotspot" file paths for GitHub tokens and npm tokens. There are 80+ encrypted paths for Linux, 40+ for macOS, and 12 for Windows, covering .npmrc, .gitconfig, .env files, shell history, and browser credential stores:

class FilesystemCollector extends BaseCollector {
  constructor() {
    super('filesystem', 'token-store', {
      ghtoken:  /gh[op]_[A-Za-z0-9]{36}/g,
      npmtoken: /npm_[A-Za-z0-9]{36,}/g,
    });
  }
  getHotspots() {
    const os = detectOS(); // 'LINUX', 'WIN', or 'OSX'
    return HOTSPOT_PATHS[os]

class FilesystemCollector extends BaseCollector {
  constructor() {
    super('filesystem', 'token-store', {
      ghtoken:  /gh[op]_[A-Za-z0-9]{36}/g,
      npmtoken: /npm_[A-Za-z0-9]{36,}/g,
    });
  }
  getHotspots() {
    const os = detectOS(); // 'LINUX', 'WIN', or 'OSX'
    return HOTSPOT_PATHS[os]

class FilesystemCollector extends BaseCollector {
  constructor() {
    super('filesystem', 'token-store', {
      ghtoken:  /gh[op]_[A-Za-z0-9]{36}/g,
      npmtoken: /npm_[A-Za-z0-9]{36,}/g,
    });
  }
  getHotspots() {
    const os = detectOS(); // 'LINUX', 'WIN', or 'OSX'
    return HOTSPOT_PATHS[os]

ShellEnvCollector : runs a shell command via execSync and dumps the entire process.env:




GitHubActionsCollector Only activates inside GitHub Actions on Linux (GITHUB_ACTIONS === 'true' and RUNNER_OS === 'Linux'). It pipes an AES-encrypted Python script stored in the bundle into sudo python3 via stdin. The Python script reads the Actions runner worker's internal IPC to extract every masked secret:

class GitHubActionsCollector extends BaseCollector {
  constructor() {
    super('github', 'token-store', {
      ghtoken:  /gh[op]_[A-Za-z0-9]{36,}/g,
      npmtoken: /npm_[A-Za-z0-9]{36,}/g,
      ghsjwt:   /ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
      ghs_old:  /ghs_[A-Za-z0-9]

class GitHubActionsCollector extends BaseCollector {
  constructor() {
    super('github', 'token-store', {
      ghtoken:  /gh[op]_[A-Za-z0-9]{36,}/g,
      npmtoken: /npm_[A-Za-z0-9]{36,}/g,
      ghsjwt:   /ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
      ghs_old:  /ghs_[A-Za-z0-9]

class GitHubActionsCollector extends BaseCollector {
  constructor() {
    super('github', 'token-store', {
      ghtoken:  /gh[op]_[A-Za-z0-9]{36,}/g,
      npmtoken: /npm_[A-Za-z0-9]{36,}/g,
      ghsjwt:   /ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
      ghs_old:  /ghs_[A-Za-z0-9]

The five cloud-service scanners run via executeStreaming() after the initial collectors complete:

  • AwsStsScanner : validates stolen AWS credentials via GetCallerIdentity ; retrieves account ID, ARN, and user ID

  • AwsSecretsManagerScanner : paginates through all secrets across all 17 AWS regions ( us-east-1eu-west-3 )

  • AwsSsmScanner : enumerates AWS SSM Parameter Store entries with exponential-backoff retry

  • VaultScanner : tries AWS IAM auth, then token file auth ( ~/.vault-token , /run/secrets/VAULT_TOKEN , Kubernetes service account token), then direct X-Vault-Token header auth against VAULT_ADDR

  • GitHubRepoScanner : uses stolen GitHub tokens to enumerate accessible repositories and their secrets

Exfiltration

A dispatcher fans out all collected data to two senders simultaneously.

GitHubSender : interestingly there seems to be code present that attempts to creates a new public repository under the victim's own account using a randomly generated name (two words from encrypted wordlists joined with a number, e.g. sunset-harbor-413). Stolen data is base64-encoded and committed as files, chunked at 30 MB per file so GitHub's API limits are not hit:




SessionSender : connects to the Session decentralised messenger network as a fallback C2 channel. Bootstraps by resolving Oxen snode addresses, establishes an encrypted keypair, and uploads files to filev2.getsession.org. Session is chosen because it has no central authority that can action a takedown request — the "Couldn't fetch snodes" error strings visible in the bundle are Session's own bootstrap error messages.

Self-replication

NpmWormReplicator — for each stolen npm token, fetches the list of packages the token can publish, injects the payload into each one, and publishes. Linux/macOS only; cleans up its temp directory afterwards:

class NpmWormReplicator extends BaseReplicator {
  async run() {
    if (!['linux', 'darwin']

class NpmWormReplicator extends BaseReplicator {
  async run() {
    if (!['linux', 'darwin']

class NpmWormReplicator extends BaseReplicator {
  async run() {
    if (!['linux', 'darwin']

GitHubWorkflowInjector — for GitHub tokens that have workflow scope (hasWorkflowScope), pushes a malicious workflow YAML into accessible repositories so the payload re-executes on every subsequent push, continuing the worm's spread independently of npm.

IoCs

  • Commit: tanstack/router@79ac49eedf774dd4b0cfa308722bc463cfe5885c

  • Source fork: github.com/zblgg/configuration

  • Malicious PR: tanstack/router#7378


SHARE

Subscribe Now

Subscribe Now

Subscribe Now

Ossprey helps you understand what code is trying to do,  before you trust it.

Ossprey helps you understand what code is trying to do,  before you trust it.

Related articles.

Related articles.

Related articles.