← Blog

11 mai 2026, 19:26 UTC. Socket détecte les premiers packages compromis dans le namespace @tanstack. En 12 heures, la campagne « Mini Shai-Hulud » de TeamPCP touche 200+ artefacts npm et PyPI : TanStack, Mistral AI, UiPath, OpenSearch, Guardrails AI, Squawk, et des dizaines de packages secondaires. @tanstack/react-router a 12 millions de téléchargements hebdomadaires.

MUAD'DIB a scanné 206 de ces packages entre le 11 et le 12 mai. Résultat : 93 alertés, 113 passés sous le seuil. @tanstack/react-router a scoré 2/100. Le payload était un fichier de 2.3 MB contenant 78 accès dynamiques à process.env, 30 188 variables _0x, un canal d'exfiltration vers le réseau Session, et une persistance dans .claude/settings.json.

Les règles se sont déclenchées. Le scoring les a écrasées. Cet article est l'autopsie de ce gap.

L'attaque

L'analyse technique détaillée provient de Socket, StepSecurity et Snyk. Ce qui suit est un résumé du point de vue détection.

TeamPCP a exploité une chaîne de trois vulnérabilités dans le workflow GitHub Actions de TanStack :

  1. Pwn Request : un workflow pull_request_target checkout le code du fork attaquant tout en conservant l'accès au cache du repo base
  2. Cache poisoning : le store pnpm est empoisonné via le fork, le workflow légitime de release charge les dépendances corrompues
  3. Extraction mémoire : le payload lit /proc/{pid}/mem du Runner.Worker pour extraire le token OIDC en clair (les secrets GitHub Actions sont masqués dans les logs mais pas chiffrés en RAM)

Le token OIDC est échangé contre un droit de publication npm via la fédération trusted-publisher. Les packages publiés portent des attestations SLSA Build Level 3 valides. C'est le premier cas documenté de packages malveillants avec provenance Sigstore authentique.

Le payload : router_init.js

Un fichier unique de 2.3 MB, une seule ligne, trois couches d'obfuscation imbriquées :

CoucheTechniqueObjectif
1String-array rotation (obfuscator.io)Variables _0x253b, 30 188 identifiants hex
2beautify(), PBKDF2 cipher148 noms d'env vars chiffrés, invisibles à grep
3AES-256-GCM + Bun.gunzipSyncPayload secondaire décompressé au runtime Bun

Capacités du payload :

  • Credential sweep : GitHub Actions (OIDC token, secrets via API), AWS (IMDSv2, Secrets Manager, SSM), Vault (svc.cluster.local:8200), Kubernetes (serviceaccount token)
  • Exfiltration : réseau Session P2P (filev2.getsession.org) avec protobuf signalservice embarqué. Trafic indiscernable d'une app de messagerie chiffrée au niveau réseau. Second canal : dead-drop GitHub GraphQL, commits signés claude@users.noreply.github.com
  • Persistance : écriture dans .claude/settings.json (hooks Claude Code) et .vscode/tasks.json (auto-run VS Code). Survit à npm uninstall et aux reboots
  • Worm npm : acquisition OIDC → publication automatique sous l'identité du mainteneur compromis, avec attestations Sigstore valides
  • Dead man's switch : le token npm porte le message IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner. Un service systemd/launchctl (com.user.gh-token-monitor) surveille le token et déclenche une destruction du home directory si révoqué

Le choix de Bun comme runtime est tactique : il bypass les hooks --require utilisés par les outils de monitoring Node.js (dd-trace, opentelemetry, newrelic). Et le && exit 1 sur le script prepare fait « échouer » l'optional dependency proprement, sans log suspect, pendant que le child process détaché continue en background.

Ce que MUAD'DIB a détecté

Sur le tarball réel de @tanstack/react-router@1.169.8, 17 règles se sont déclenchées :

RègleTypeSévérité originaleSévérité finaleRaison du downgrade
MUADDIB-AST-018env_charcode_reconstruction (x78)HIGHLOWunreachable
MUADDIB-AST-002env_access (x78 dynamic)MEDIUMLOWunreachable
MUADDIB-AST-002env_access (ACTIONS_ID_TOKEN_REQUEST_TOKEN)HIGHLOWunreachable + count > 4
MUADDIB-AST-002env_access (AWS_SECRET_ACCESS_KEY)HIGHLOWunreachable + count > 4
MUADDIB-AST-002env_access (VAULT_TOKEN, VAULT_AUTH_TOKEN)HIGHLOWunreachable + count > 4
MUADDIB-AST-041credential_regex_harvestHIGHLOWunreachable
MUADDIB-OBF-001obfuscation_detected (score 80)LOWLOWisLargeJs (> 100KB)
MUADDIB-FLOW-001suspicious_dataflow (creds → fetch x9)CRITICALLOWunreachable
MUADDIB-ENTROPY-003js_obfuscation_pattern (30 188 _0x vars)HIGHLOWunreachable + count > 1
MUADDIB-ENTROPY-001high_entropy_string (x3)MEDIUMLOWunreachable

Score final : 2/100. Seuil d'alerte pour un package établi : 35. Pas d'alerte envoyée.

Trois causes racines

Le score de 2 n'est pas un bug unique. C'est la composition de trois mécanismes de FP reduction qui fonctionnent correctement sur les packages bénins mais que l'attaquant exploite, consciemment ou non.

1. Reachability : le fichier injecté est invisible au module-graph

router_init.js n'est importé par aucun fichier du package. Le module-graph le marque unreachable. Toutes les détections non exemptées passent à LOW (scoring.js:1106). C'est le mécanisme qui a le plus d'impact : il transforme 2 CRITICAL + 6 HIGH en 17 LOW.

Le fichier s'exécute en réalité via optionalDependenciesgithub:tanstack/router#79ac49eeprepare: "bun run tanstack_runner.js". C'est un vecteur d'exécution que le module-graph ne trace pas : la dépendance git exécute un hook lifecycle dans un sous-package, pas un require() depuis le code principal.

2. isLargeJs : un fichier de 2.3 MB est forcément un bundle

L'obfuscation scanner (obfuscation.js:69) traite tout fichier .js > 100 KB comme du « bundled output » et émet les findings à LOW :

// P6: Any JS file > 100KB is overwhelmingly bundled output
const isLargeJs = basename.endsWith('.js') && content.length > 100 * 1024;
const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeJs;

L'hypothèse est statistiquement correcte :99%+ des fichiers JS > 100 KB sont du webpack, rollup ou esbuild. Mais un payload javascript-obfuscator de 2.3 MB n'est pas un bundle, et le scanner ne fait pas la différence.

3. MT-1 : pas de lifecycle = pas de score > 35

Le plafond MT-1 (scoring.js:1401) limite le score à 35 pour les packages sans lifecycle script. @tanstack/react-router n'a ni preinstall, ni install, ni postinstall. L'exécution passe par l'optionalDependencies git, pas par un script du package principal.

Le bypass MT-1 vérifie la présence de HIGH_CONFIDENCE_MALICE_TYPES. env_charcode_reconstruction n'y était pas.

Cinq correctifs

FichierChangementImpact
scoring.js env_charcode_reconstruction ajouté à REACHABILITY_EXEMPT_TYPES fromCharCode + process.env[computed] est structurellement unique au malware. Le finding reste HIGH même sur un fichier unreachable.
scoring.js Les findings co-localisés avec un type exempt sont eux aussi exemptés du downgrade reachability Si un fichier contient env_charcode_reconstruction (preuve de malice), les obfuscation_detected CRITICAL et suspicious_dataflow CRITICAL du même fichier ne sont plus écrasés.
classify.js env_charcode_reconstruction ajouté à HIGH_CONFIDENCE_MALICE_TYPES Bypass du plafond MT-1 (35). Le score n'est plus artificiellement cappé pour les packages sans lifecycle direct.
obfuscation.js isLargeJs vérifie l'absence de markers _0x dans les premiers 8 KB webpack/rollup/esbuild ne produisent jamais de variables _0x. Un fichier > 100 KB avec des _0x est du javascript-obfuscator, pas du bundle output.
package.js PKG-014 étendu pour matcher github:, gitlab:, bitbucket: La regex /^git[+:]/ ne matchait pas github:tanstack/router#commit. Le format shorthand exécute des hooks prepare à l'installation.
bundle-detect.js detached_credential_exfil, ai_config_injection, ide_task_persistence ajoutés à VETO_TYPES Ces patterns ne sont jamais produits par un bundler. Un fichier dist/ qui spawn un detached process + lit des credentials + écrit dans .claude/ n'est pas du code bundlé.

Résultat : avant / après

Scan de @tanstack/react-router@1.169.8 (tarball réel de la campagne) :

AvantAprès
Score2/100 LOW100/100 CRITICAL
Findings CRITICAL02 (obfuscation_detected, suspicious_dataflow)
Findings HIGH08 (env_charcode, env_access x5, credential_regex, js_obf, git_dep_rce)
Alerte envoyéeNonOui
Tests passants878878 (0 régression)

Échantillon de 18 packages re-scannés sur les tarballs archivés (tous les namespaces de la campagne : @tanstack, @mistralai, @squawk, @beproduct, @draftlab, @supersurkhet, @tolka, @ml-toolkit-ts, git-git-git, nextmove-mcp, cmux-agent-mcp, safe-action, dirigible-ai) :

PackageScore avantScore après
@tanstack/react-router@1.169.82100
@tanstack/react-start@1.167.712100
@tanstack/solid-router@1.169.82100
@tanstack/history@1.161.122100
@mistralai/mistralai-azure@1.7.317100
@squawk/mcp@0.9.223100
git-git-git@1.0.89100
nextmove-mcp@0.1.323100
safe-action@0.8.415100
@dirigible-ai/sdk@0.6.317100
@beproduct/nestjs-auth@0.1.1925100
@draftlab/auth@0.24.217100

Re-scan complet des 206 tarballs archivés :

AvantAprès
Score == 1000165 (80%)
Score ≥ 350168 (81%)
Score ≥ 2093 (45%)170 (82%)
Score < 20113 (55%)36 (18%)

Les 36 restants sous le seuil sont des packages adjacents dans l'archive qui n'ont pas été compromis : 14 @tanstack/preact-* sans payload (versions légitimes publiées pendant la même fenêtre), des @uipath/packager-* dont les tarballs n'ont pas le router_init.js, et quelques mobx-tanstack-query-api qui sont des packages tiers non ciblés par TeamPCP.

Ce que MUAD'DIB ne détecte toujours pas

  • PyPI (mises à jour de packages existants) : le monitor PyPI consomme le flux RSS pypi.org/rss/packages.xml, qui ne liste que les nouveaux packages, pas les nouvelles versions de packages existants. mistralai@2.4.6 et guardrails-ai@0.10.1 sont des mises à jour de packages établis. Ils ne sont jamais apparus dans le feed, jamais téléchargés, jamais scannés. Le feed PyPI a bien tourné pendant la fenêtre de compromission (18 détections PyPI entre le 11 et le 12 mai), mais il ne capte structurellement pas ce type de publication.
  • IMDS :l'adresse 169.254.169.254 (AWS metadata) n'a pas de règle dédiée. Le dataflow la capture indirectement s'il co-existe avec un env_access, mais l'IP elle-même n'est pas flaggée.
  • Session P2P :filev2.getsession.org n'est pas dans les IOC strings. Le trafic vers ce domaine est indiscernable de l'usage légitime de l'app Session au niveau réseau.
  • Provenance Sigstore :MUAD'DIB ne vérifie pas les attestations SLSA. Même si elle l'avait fait, les attestations étaient techniquement valides. SLSA prouve que le workflow a signé le package, pas que le code est sûr.
  • Le dead man's switch :le service systemd/launchctl de destruction conditionnelle (com.user.gh-token-monitor) n'a pas de détection dédiée.

IOC

Fichiers

# router_init.js (payload principal)
SHA256  ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c
SHA1    12ed9a3c1f73617aefdb740480695c04405d7b4b

# tanstack_runner.js (variante 2, propagation worm)
SHA256  2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96
SHA1    e7d582b98ca80690883175470e96f703ef6dc497

# Commit malveillant dans TanStack/router
79ac49eedf774dd4b0cfa308722bc463cfe5885c

Réseau

filev2.getsession[.]org      # exfiltration Session P2P
api.masscan[.]cloud           # C2 direct (StepSecurity)
git-tanstack[.]com            # domaine C2 pour payload Python guardrails-ai

Persistance

.claude/router_runtime.js
.claude/settings.json          # hooks Claude Code
.claude/setup.mjs
.vscode/setup.mjs
.vscode/tasks.json             # auto-run VS Code
com.user.gh-token-monitor      # service systemd/launchctl dead man's switch

Identités

voicproducoes                  # compte GitHub attaquant
claude@users.noreply.github.com  # auteur spoofé des commits dead-drop

Sources

Leçon

Les règles de détection ont fonctionné. Le scoring les a écrasées. C'est un pattern récurrent en sécurité : l'ingénierie de fiabilité (réduction des faux positifs) et l'ingénierie de détection (couverture des vrais positifs) se battent pour les mêmes leviers. Chaque threshold conçu pour supprimer les bundles bénins est un threshold qu'un attaquant peut exploiter.

TeamPCP ne contourne pas les détections. Ils contournent le scoring. Leur payload de 2.3 MB exploite l'hypothèse « gros fichier = bundle inoffensif ». Leur absence de lifecycle script exploite le plafond MT-1. Leur fichier injecté exploite le downgrade reachability. Trois assumptions raisonnables individuellement, catastrophiques en combinaison.

La leçon pour MUAD'DIB : les signaux structurellement uniques au malware :env_charcode_reconstruction, function_constructor_require, detached_credential_exfil, ne doivent jamais être atténués par des heuristiques statistiques. Si un fichier reconstruit des noms de variables d'environnement via String.fromCharCode, aucune quantité de FP reduction ne change ce que ça signifie.

La leçon plus large : les attestations SLSA, les badges provenance, les Sigstore transparency logs :tout ce qui prouve qui a signé, ne prouve pas quoi a été signé. Un pipeline compromis produit des attestations parfaitement valides pour du code parfaitement malveillant. La supply chain n'est pas sécurisée par la provenance. Elle est sécurisée par l'analyse du contenu.