# jscrambler (npm) : un credential volé, un ver Rust dans le package

**Catégorie :** Supply Chain

**Analyste :** Gordon PEIRS

**Famille :** IronWorm (infostealer Rust, lignée Shai-Hulud), livré via compromission de la chaîne de publication npm

**Type :** Analyse statique uniquement (aucune exécution des binaires ni du JS malveillant). Extraction et parsing faits par un script Python maison qui rejoue la logique du loader sans jamais l'invoquer.

---

## Résumé

Le 11 juillet 2026, le package npm légitime `jscrambler` (des dizaines de milliers de téléchargements par semaine) est compromis avec un credential de publication volé. Cinq versions malveillantes sortent en quelques heures, avant qu'une version saine soit republiée. Le payload est IronWorm, un infostealer en Rust de la lignée Shai-Hulud, avec une cible inhabituelle : les credentials d'outils IA (Claude Desktop, Cursor, Windsurf, VS Code, Zed), en plus du classique cloud, wallet et navigateur. Je n'ai rien exécuté, l'analyse est statique : triage `capa` des trois binaires extraits (Linux et Windows, macOS n'étant pas supporté), extraction de chaînes avec `floss`, qui fait apparaître le protocole C2, et confirmation par `readelf` de la capacité de rootkit eBPF déjà documentée par des tiers, via la dépendance `libbpf.so.1`. Le blob de configuration C2 reste chiffré malgré plusieurs tentatives (désassemblage radare2/r2dec, émulation Qiling). Le vrai C2, une infrastructure Tor, est confirmé par recoupement avec l'analyse dynamique de JFrog. J'ai aussi localisé le mécanisme de propagation en ver (la « self-dependency ») et écrit des règles YARA, testées sans faux positif sur les versions saines du package.

---

## 1. Contexte

Le 11/07/2026, le package npm **`jscrambler`** (outil commercial légitime d'obfuscation JavaScript, des dizaines de milliers de téléchargements hebdomadaires) a été compromis via un credential de publication volé. Cinq versions malveillantes ont été publiées en l'espace de quelques heures : `8.14.0`, `8.16.0`, `8.17.0`, `8.18.0`, `8.20.0`, avant qu'une version saine (`8.22.0`) ne soit republiée.

Avant de commencer l'acquisition, j'ai lu les sources publiques : BleepingComputer, The Hacker News, SC Media, Socket (qui a détecté l'attaque 6 minutes après la publication) et JFrog (qui a identifié le payload comme `IronWorm` et l'a rattaché à la lignée **Shai-Hulud**, le ver npm déjà documenté).

J'ai choisi ce sujet plutôt qu'un nouvel infostealer PE classique : le vecteur de livraison est complètement différent (supply chain npm et non dropper/crypter), le langage aussi (Rust confirmé, cf. §4), et le ciblage est inédit (credentials d'outils IA, Claude Desktop, Cursor, Windsurf, VS Code, Zed, en plus du classique cloud/wallet/navigateur).

## 2. Acquisition : ce qui est récupérable et ce qui ne l'est plus

Le registre npm officiel a purgé la plupart des versions malveillantes. J'ai essayé plusieurs canaux pour les récupérer quand même :

| Version | Statut sur `registry.npmjs.org` | Récupérée via |
|---|---|---|
| 8.14.0 | absente de `versions`, présente dans `time` (tombstone) | **jsDelivr** (cache indépendant du registre) |
| 8.15.0 | encore listée officiellement, mais aussi testé pour confirmer un cache indépendant | **npmmirror.com** (CDN chinois, cache indépendant lui aussi) |
| 8.16.0 | absente, ni jsDelivr, ni unpkg, ni npmmirror (redirection CDN mais 404 XML derrière), ni Wayback Machine (CDX vide), ni registre direct | **non récupérable, 5 canaux testés** |
| 8.17.0 | idem | **non récupérable, 5 canaux testés** |
| 8.18.0 | **encore listée et servie normalement** | registre npm officiel, directement |
| 8.20.0 | idem 8.16.0/8.17.0 | **non récupérable, 5 canaux testés** |
| 8.22.0 (safe) | listée | registre npm officiel, référence saine pour diff |

À noter : `8.18.0`, une des versions que la presse cite comme malveillantes, est toujours servie telle quelle par le registre officiel au 19/07/2026. La purge de npm n'a pas été complète.

**J'ai inspecté la 8.15.0 : c'est un vrai correctif temporaire, pas une version malveillante.** Son `dist/index.js` est strictement identique (même SHA256, `5d39319f...`) à celui de la version saine 8.22.0. Il n'y a ni hook `preinstall` ni fichier `dist/intro.js`. Elle est publiée à 17:07 UTC le 11/07, environ 2h après la première version malveillante (8.14.0, 15:12 UTC).

**Ce correctif n'a pas tenu** (advisory OSV `MAL-2026-10187`, voir §3.4). L'attaquant avait visiblement toujours le credential de publication, et il a republié du code malveillant dès 17:26 UTC (8.16.0), 19 minutes après le correctif. Pendant environ 1h, la campagne a alterné tentatives de correction légitimes et republications malveillantes, jusqu'au correctif définitif (8.22.0, 18:12 UTC).

Les versions 8.16.0, 8.17.0 et 8.20.0 restent introuvables après 5 canaux testés (registre direct, jsDelivr, unpkg, npmmirror avec suivi de la redirection CDN, API CDX de la Wayback Machine). Leur comportement est connu par des sources tierces (OSV/safedep, §3.4), je ne l'ai pas vérifié sur le binaire.

## 3. Le mécanisme de livraison, et sa mutation en cours de campagne

### 3.1 8.14.0 : hook `preinstall` classique

`package.json` de 8.14.0 contient :
```json
"scripts": { "preinstall": "node dist/setup.js", ... }
```

`dist/setup.js` (de l'ESM lisible, non minifié) lit `dist/intro.js` et vérifie un magic maison, `\x1bCSI\x01`. Si le conteneur a une entrée pour l'OS courant, il en extrait le binaire, le décompresse (gzip) dans un fichier temporaire au nom aléatoire et le lance en détaché (`spawn(..., {detached: true}); proc.unref()`). Petit détail d'évasion : la chaîne `.exe` n'apparaît jamais telle quelle dans le code, elle est reconstruite avec `String.fromCharCode(46,101,120,101)`.

### 3.2 8.18.0 : même charge utile, déclencheur différent

Le `package.json` de la 8.18.0 **n'a plus de hook `preinstall`**. La logique de `setup.js` a été copiée telle quelle dans une IIFE auto-invoquée, injectée dans `dist/index.js` **et** dans `dist/bin/jscrambler.js`. Elle s'exécute donc dès que le module est chargé (`require()`), et plus seulement à l'installation.

**Le blob `intro.js` est strictement identique (même SHA256) entre les deux versions** : `a41a523ef9517aab37ed6eea0ec881821bdcb7aefcb5c5f603adc7907f868c86`. Seul le déclencheur a changé, pas la charge.

**L'hypothèse la plus probable** : le hook `preinstall` est un signal que les scanners de supply chain (Socket, npm audit, etc.) connaissent bien. Le remplacer par du code qui s'exécute au chargement normal du module, c'est contourner ce signal précis, sans rien changer au fonctionnement. Aucune source externe ne le confirme à ce jour.

### 3.4 Progression complète de la campagne (recoupement OSV/GHSA/safedep)

Les 3 versions non récupérées directement (§2) sont documentées par un rapport tiers (safedep.io, référencé par l'advisory OSV `MAL-2026-10187` et GHSA `GHSA-v3g3-4x77-rw68`) qui a manifestement eu accès aux tarballs complets. Recoupé avec mes propres constats sur 8.14.0/8.15.0/8.18.0 (identiques sur les points communs, ce qui donne confiance dans le reste) :

| Version | Heure (UTC, 11/07) | Mécanisme | Vérifié ici ? |
|---|---|---|---|
| 8.14.0 | 15:12 | hook `preinstall` (`dist/setup.js`) | Oui, directement (§3.1) |
| 8.15.0 | 17:07 | correctif propre | Oui, directement (§2) |
| 8.16.0 | 17:26 | même payload `preinstall` réintroduit | Non, source tierce uniquement |
| 8.17.0 | 17:41 | `preinstall` + **contournement de l'allowlist pnpm** | Non, source tierce uniquement |
| 8.18.0 | 17:46 | IIFE injectée dans `index.js`/`bin/jscrambler.js` au chargement | Oui, directement (§3.2) |
| 8.20.0 | 17:53 | identique à 8.18.0 + une "self-dependency" | Non, source tierce uniquement |
| 8.22.0 | 18:12 | correctif définitif | Oui, indirectement (référence saine utilisée pour tous les diffs) |

Deux points notables rapportés par la source tierce, non vérifiables sans les binaires correspondants :
- **8.17.0** aurait contourné l'allowlist de scripts de pnpm, un mécanisme qui existe justement pour bloquer les scripts `preinstall` non explicitement autorisés par l'utilisateur. Si confirmé, c'est une évasion ciblée contre une mitigation spécifique, pas juste contre les scanners.
- **8.20.0** aurait une « self-dependency », sans doute liée à l'auto-propagation vers d'autres packages (ce qui colle avec l'attribution IronWorm/Shai-Hulud, une lignée de vers npm qui se répliquent). Je la retrouve sur la 8.18.0 au §8.

La source tierce confirme aussi que le blob `dist/intro.js` (SHA256 `a41a52...`) est identique sur **toutes** les versions malveillantes, pas seulement sur les deux que j'ai comparées.

### 3.5 Format du conteneur `intro.js`

Repris dans [`tools/extract_intro_payloads.py`](writeups/jscrambler-npm-supplychain/tools/extract_intro_payloads.py) (validé : hashs identiques à l'extraction manuelle) :

```
[5 octets magic : 1b 43 53 49 01]
[1 octet : nombre d'entrées]
répété N fois :
  [1 octet : id plateforme (0=linux, 1=win32, 2=darwin)]
  [8 octets : réservé, jamais lu par le loader lui-même]
  [8 octets LE uint64 : taille compressée]
  [taille_compressée octets : payload compressé gzip]
```

3 entrées dans le conteneur observé, une par plateforme. Aucun octet de fin de fichier non consommé (parsing complet vérifié).

## 4. Les trois binaires extraits

Extraits sans exécution via `tools/extract_intro_payloads.py` :

| Plateforme | Format | SHA256 (décompressé) | Taille décompressée |
|---|---|---|---|
| Linux | ELF 64-bit LSB PIE x86-64, dynamiquement lié, **stripped**, BuildID `c05bf7dc45672d7a59cbd5e300f586482da14d00` | `fbbcf4d8f98168f78f5c0c47a9ae56d59ec8ac84a7c9ca6b797fedfb8d62d2bd` | 4 521 128 octets |
| Windows | PE32+ x86-64, 6 sections, console | `b7ca95d1b23c8e67416a25cedf741de0917c2096bbc9d24649eea7853d054903` | 5 155 328 octets |
| macOS | Mach-O 64-bit arm64 (PIE, TWOLEVEL) | `c8fd47d36bdf7c825378593ab82ed8c24d1dc52e26b507812393e24e1d5201fd` | 3 184 979 octets |

**Confirmation du langage** : chaîne `rustc version 1.98.0-nightly (e7815e522 2026-06-04)` trouvée en clair dans le binaire Linux. Le binaire Windows contient la chaîne `docs.rs/rustls/...` (voir §6), ce qui confirme indirectement Rust là aussi (rustls est une bibliothèque Rust, jamais utilisée depuis un autre langage). Je n'ai pas de confirmation directe équivalente pour le binaire macOS.

## 5. Triage `capa` (Linux + Windows ; macOS non supporté)

`capa` ne supporte pas du tout le format Mach-O. Je l'ai vérifié dans son code : les formats gérés sont `pe, dotnet, elf, sc32, sc64`, plus les rapports de sandbox. **C'est une limite de l'outil**, et le binaire macOS reste hors de portée de `capa`.

Pour le PE Windows, `capa` a besoin des fichiers `.sig` FLIRT, qu'il ne distribue plus sur PyPI. Je les ai pris dans le dépôt GitHub officiel (`mandiant/capa/sigs/`) et installés dans `tools/capa-sigs/`.

### 5.1 Linux (ELF)

- **Cryptographie** : AES (chiffrement et déchiffrement), RC4, Salsa20/ChaCha, SHA256, SHA512
- **Encodage** : Base64, et surtout **Base58**, ce qui colle avec un ciblage d'adresses de wallets crypto (Bitcoin, Solana et consorts utilisent Base58)
- **Évasion** : exécution de syscalls directs (11 correspondances), technique Rust classique pour contourner les hooks posés sur la libc
- **Discovery** : lecture de variables d'environnement

### 5.2 Windows (PE) : profil bien plus riche

Le PE profite de bien plus de règles `capa` (l'écosystème de règles est historiquement centré sur Windows). Ce qui ressort :

- **Réseau, confirmé comme capacité C2** : la classification MBC matche explicitement `COMMAND AND CONTROL / C2 Communication::Send Data` et `::Receive Data`, en plus de sockets Winsock, résolution DNS, configuration de socket.
- **Pile crypto complète** : AES, RC4, **RSA**, **Curve25519**, Salsa20/ChaCha, SHA1/256/384/512, HMAC, CRC32, PRNG via `RtlGenRandom`.
- **`linked against sqlite3`** : confirme statiquement que le binaire embarque un moteur SQLite complet, cohérent avec le vol de `Login Data`/`Cookies` (bases SQLite de Chrome/Chromium/Brave/Edge).
- **`parse credit card information`** (namespace `collection/credit-card`) : capacité détectée qui n'apparaît dans aucun des rapports publics consultés (BleepingComputer, JFrog, safedep), probablement le parsing de la table `Web Data` de Chrome, qui stocke aussi les cartes bancaires enregistrées, pas seulement les mots de passe. Point original, non mentionné par les sources publiques consultées.
- **`linked against wolfSSL` : un faux positif.** `strings -n 5 payload_win32.exe | grep -i wolfssl` ne remonte rien. Aucune chaîne `wolfSSL_*` ou `wolfssl` n'existe dans le binaire, alors que `rustls` (une bibliothèque TLS en pur Rust) y figure en toutes lettres (`docs.rs/rustls`, voir §6). La règle `capa` recoupe sans doute des constantes cryptographiques génériques (S-box AES, courbes elliptiques) communes à plusieurs bibliothèques. La vraie pile TLS de ce binaire est **rustls**.
- **`interact with driver via IOCTL`** : capacité détectée, dont je n'ai pas cherché la signification exacte.

## 6. `floss` : chaînes statiques et protocole C2 confirmé

Pour son décodage avancé (chaînes empilées ou décodées), `floss` ne supporte **que le format PE**. Il le dit lui-même quand on essaie sur Linux ou macOS : *"FLOSS currently supports the following formats for string decoding and stackstrings: PE"*. Je ne l'ai donc passé que sur le binaire Windows.

Les catégories `decoded_strings`/`tight_strings` de floss sont pensées pour des malwares packés avec un XOR classique. Sur ce binaire Rust, elles ne remontent presque que du bruit, ce qui est normal : Rust ne produit pas le motif de désobfuscation sur la pile que ces heuristiques cherchent. Le signal utile est dans les **`static_strings`** (77 432 chaînes), où on trouve :

- **Protocole C2 confirmé** (recoupe exactement la description de safedep, §3.4/source tierce) : `{"t":"out","id":"`, `,"progress":true}`, `{"t":"done","id":"`, un protocole RAT JSON avec un identifiant de tâche et un statut de progression.
- **Vol de session Steam confirmé** : chaîne concaténée `steamLoginSecure` + `sessionid` + `browserid` + `timezoneOffset`, ce qui colle avec un template de payload JSON envoyé au C2.
- **Moteur SQLite embarqué en clair** : requêtes SQL complètes du code source SQLite (`CREATE TABLE %Q.sqlite_sequence...`, `fts3_tokenizer`, etc.), confirme au niveau code source ce que `capa` avait détecté au niveau capacités (§5.2).
- **rustls confirmé** : `"...https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html..."`, un message d'erreur du crate `rustls` compilé tel quel dans le binaire. C'est la vraie bibliothèque TLS (et non wolfSSL, voir §5.2).
- **Ciblage EC2 IMDS, point original non rapporté ailleurs** : chaîne `ec2-imds` trouvée à la fois sur Linux (`strings`) et dans les static strings Windows, cible le **service de métadonnées d'instance EC2** (`169.254.169.254`), utilisé pour voler les credentials IAM temporaires d'un rôle EC2. Ni BleepingComputer, ni JFrog, ni safedep ne mentionnent ce détail explicitement (ils parlent d'AWS Secrets Manager/SSM, pas d'IMDS), capacité complémentaire, pas rapportée ailleurs à ma connaissance.
- **`bip39_english`** et `general_mnemonic` confirmés en clair (déjà rapportés par safedep, revérifiés indépendamment ici), moissonnage de seed phrases de wallets.

Aucun domaine ni aucune IP en clair, nulle part. C'est cohérent avec le §7 : la config C2 est chiffrée, pas statique.

## 7. Où est le C2 ? Chiffré, pas en clair

Le rapport safedep (source tierce) situe la config C2 dans un blob de ~80 Ko à haute entropie (>7.9 bits/bloc de 4 Ko) dans la section `.rdata` du PE, offset `0x326400`, déchiffré au démarrage via AES-NI matériel (263 sites `aesenc`/`aesdec`/`aeskeygenassist`) + PBKDF2/scrypt dérivés d'une passphrase codée en dur.

Sur le binaire **Linux**, un scan d'entropie par blocs de 4 Ko donne 9 régions à haute entropie dans `.rodata` (offsets `0x1e000` à `0xba000`), et non un seul blob propre de 80 Ko comme sur le PE. Soit le format du blob diffère d'une plateforme à l'autre, soit ces régions sont des données Rust ordinaires (tables de hachage, chaînes internées) sans rapport avec la config C2. Le scan d'entropie brut ne permet pas de trancher ; il faudrait partir des cross-références aux fonctions AES identifiées par `capa`.

### 7.1 Vérification indépendante du blob

J'ai refait moi-même la localisation et la caractérisation du blob, sans dépendre de la description de safedep : sections PE lues via `pefile` (`.rdata` : VA `0x323000`, offset fichier `0x322400`, taille `0x1b0e00`). L'offset `0x326400` cité par safedep tombe bien dans `.rdata` (à `0x4000` du début de la section). Scan d'entropie par blocs de 4 Ko sur exactement 80 Ko à partir de cet offset : entropie moyenne 7.86/8, 16 blocs sur 19 au-dessus de 7.9, confirme de son côté la description du blob (chiffré, pas juste "haute entropie par hasard").

`capa -vv` m'a permis de localiser, à l'adresse `0x1401570B2` (image base `0x140000000`), une fonction qui enchaîne une série de `aeskeygenassist`. C'est très probablement l'expansion de clé AES (key schedule), ce qui correspond à l'AES-NI matériel décrit par safedep. **Il n'existe pas de règle capa dédiée à PBKDF2 ou à scrypt** (capa ne les modélise pas), donc cette voie ne permet pas de localiser la dérivation de clé.

**En cherchant la passphrase** : recherche de chaînes ASCII de 8 à 40 caractères dans `.rdata` uniquement (430 candidats après filtrage du bruit de bibliothèques connues). Aucune passphrase plausible, mais on y trouve le template XML complet de la tâche planifiée Windows, en clair :

```xml
<RegistrationInfo><Description>...</Description></RegistrationInfo>
<Triggers>...</Triggers>
<Principals><Principal id="A">...</Principal></Principals>
<Settings>
  <Hidden>true</Hidden>
  <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
  <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
  <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
</Settings>
<Actions Context="A"><Exec><Command>...</Command></Exec></Actions></Task>
```

(les octets non-ASCII entre balises sont des points d'insertion runtime, description, déclencheur, commande réelle, pas de la donnée corrompue.) Ça confirme de son côté, directement dans le binaire, le mécanisme de persistance Windows que safedep décrivait (`Hidden=true`, `PT1M`, 999 tentatives).

Aussi retrouvé au passage : l'alphabet Base58 complet en clair (`123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefg...`, sans 0/O/I/l, confirme concrètement la capacité "reference Base58 string" de `capa`, §5.1/5.2) et `x25519_server_pub` (échange de clé X25519, partie normale d'une négociation TLS via rustls, pas spécifique à l'attaquant).

**Bilan** : le blob est localisé et caractérisé de mon côté, et la persistance Windows est confirmée en clair dans le binaire. La passphrase et l'algorithme exact de dérivation, eux, restent introuvables.

### 7.2 Tentative de désassemblage ciblé (radare2, analyse statique uniquement)

`radare2` est utilisé uniquement en analyse statique (`r2 -A`, jamais `-d` ni le mode debugger, le binaire n'est exécuté à aucun moment). Une fois `aaa` terminée, il identifie 6160 fonctions.

`0x1401570B2` (repéré par `capa` en §5.2) est en fait une fonction de 11 octets, 1 seul appelant : `aeskeygenassist xmm0, [rdx], 1 ; movdqa [rcx], xmm0 ; ret`, confirme qu'il s'agit d'une étape d'expansion de clé AES (round constant 1). Son unique appelant, `fcn.140149690` (1938 octets, 386 instructions), est une routine complète d'expansion de clé AES générique, mais elle a **5 points d'appel distincts** dans le binaire (`case.0x1400384da.29`, `fcn.1400e03da`, `fcn.14011954b`, `fcn.14014627a`, `fcn.140149fd1`). C'est une routine partagée (vraisemblablement utilisée à la fois par `rustls` pour TLS et par le déchiffrement de la config), pas une fonction dédiée qu'on pourrait attribuer à coup sûr au C2.

J'ai cherché les xrefs automatiques de r2 vers l'adresse virtuelle du blob (`0x140327000`, calculée depuis l'offset fichier `0x326400` donné par safedep) : **aucune**. Le blob est sans doute lu à travers un calcul d'adresse (arithmétique de pointeur, table de relocation) que l'analyse statique automatique de r2 ne résout pas.

**Conclusion** : pour savoir lequel des 5 appelants correspond au déchiffrement de la config C2, il faudrait remonter à la main chaque chemin d'appel jusqu'à trouver celui qui référence le blob. Ce sont plusieurs heures de travail que je n'ai pas engagées. La structure de la routine AES est confirmée, adresse à l'appui, mais le déchiffrement reste hors de portée.

### 7.3 Poursuite avec r2dec (décompilation pseudo-C) : la chaîne d'appel s'approfondit mais ne se termine pas

J'ai installé [`r2dec`](https://github.com/wargio/r2dec) (par `r2pm`) pour avoir du pseudo-C, plus rapide à lire que l'assembleur. C'est un plugin communautaire léger qui travaille sur ce que r2 a déjà désassemblé, sans reparser le fichier brut. Il n'ajoute donc pas de risque par rapport à r2 seul.

Une fois décompilée, `fcn.140149690` (l'expansion de clé AES) correspond exactement au motif d'école de l'expansion de clé AES-NI (`aeskeygenassist` + `pshufd` + `pslldq` + XOR en cascade). C'est une primitive crypto générique, sans rien de propre à l'attaquant.

J'ai remonté l'un des 5 appelants (`fcn.140149fd1`, 176 octets). C'est un constructeur du genre `AesGcm::new(key: [u8;32], ...)`, qui vérifie `key_len == 0x20` (AES-256) avant d'initialiser. La clé y arrive déjà en **paramètre**, elle n'est pas dérivée sur place. La dérivation, et donc la passphrase littérale, est encore plus haut dans la chaîne d'appel.

En remontant encore (`axt` sur `fcn.140149fd1`), on trouve **3 nouveaux appelants** (`fcn.1400dfca3`, `fcn.140128bec`, `fcn.1401421da`, de 991/3205/261 octets). J'ai décompilé le plus petit (`fcn.1401421da`) : toujours du code générique de manipulation de buffers, sans référence visible à une chaîne littérale.

**Bilan de cette remontée** : la chaîne d'appel `expansion de clé → init AES-256 → 3 appelants supplémentaires` s'allonge à chaque niveau, et je n'ai pas atteint un point où une chaîne codée en dur apparaît clairement. Rien ne dit que c'est *impossible* à trouver en statique. C'est l'empilement normal de couches de bibliothèques crypto Rust génériques (dérivation de clé, construction du chiffreur, primitive AES-NI), et le code propre à l'attaquant, celui qui fournit la passphrase, est probablement plusieurs niveaux plus haut. Continuer, ce serait répéter la même démarche (décompiler, repérer les appelants, décompiler chacun) sur un nombre de niveaux inconnu d'avance. Ça demande du temps, il n'y a pas de blocage de principe.

### 7.4 Recoupement externe : le vrai C2 est documenté par JFrog (analyse dynamique tierce), et il est basé sur Tor

Plutôt que de prolonger indéfiniment le désassemblage statique, j'ai cherché des sources tierces qui ont réellement exécuté ce payload. Une confirmation externe fiable vaut mieux que s'acharner sur une voie bloquée.

**[JFrog Security Research](https://research.jfrog.com/post/ironworm-returns-rustier-than-ever/)**, qui a analysé dynamiquement ce payload (exécution réelle en sandbox, ce que je me suis interdit de faire), documente un mécanisme C2 entièrement différent de ce qu'une simple recherche de domaine en clair aurait pu trouver : **le C2 passe par un service caché Tor**. Le malware télécharge le bundle Tor, écrit sa propre config `torrc`, démarre le démon Tor localement, attend que le circuit soit monté, puis fait du beaconing HTTP en clair (à l'intérieur du tunnel Tor chiffré) vers `/api/agent` sur l'adresse `.onion` de l'attaquant. JFrog documente 3 commandes C2 : upload de credentials/secrets exfiltrés, dépôt de fichiers arbitraires fournis par l'attaquant, exécution de commandes shell distantes. Un mécanisme de repli est aussi documenté : upload des paquets de credentials vers le service public `temp.sh` via Tor en cas de coupure du canal C2 principal, avec l'URL résultante renvoyée sur le canal C2.

Deux adresses IP sont citées par JFrog : `37.27.122.124` et `57.128.246.79`. **Vérification passive indépendante** (Shodan InternetDB, aucune connexion directe faite) :

| IP | Résultat | Interprétation |
|---|---|---|
| `37.27.122.124` | hostname `torrelay-1.csguard.eu`, port 9001 ouvert, tag `tor` (Shodan) | **Relais Tor public légitime**, pas l'infrastructure de l'attaquant |
| `57.128.246.79` | hostname `caloyannis.xyz`, port 5222 (XMPP) ouvert, aucun tag Tor | Non identifiable comme infrastructure de l'attaquant, probablement un autre nœud du circuit Tor observé, pas taggé comme tel par Shodan |

**Ce que ça change** : ça explique *pourquoi* je n'ai trouvé aucun domaine ni aucune IP en clair en statique (§6). La config n'est pas seulement chiffrée avec une clé que je n'ai pas : le vrai C2 n'est probablement même pas une IP ou un domaine fixe dans le binaire, mais une adresse `.onion` (peut-être dans le blob chiffré, peut-être récupérée par un mécanisme de découverte Tor). La question « où est le C2 ? » est donc réglée par recoupement externe, même si le contenu du blob chiffré de mon échantillon reste non déchiffré (§7.5).

**Limite** : ce qui précède décrit le C2 tel que JFrog l'a documenté sur l'échantillon qu'ils ont exécuté. Je n'ai pas de confirmation directe que mon binaire (mêmes hash SHA256, §4) se comporte exactement ainsi. C'est une déduction raisonnable (même campagne, même conteneur `intro.js` identique au bit près sur toutes les versions, §3.4), pas une vérification.

### 7.5 Tentative d'émulation dynamique (Qiling) pour déchiffrer le blob de mon propre échantillon

Objectif : obtenir la config déchiffrée directement depuis **mon** binaire, sans m'appuyer seulement sur ce que JFrog a observé sur son propre run. L'idée est de regarder en dynamique quelles données passent par la routine AES repérée au §7.3, sans jamais exécuter le binaire sur le vrai système.

**Méthode** : [Qiling](https://qiling.io/) (émulateur CPU + couche OS, construit sur Unicorn), qui interprète les instructions dans un environnement entièrement virtuel, pas de vrai syscall, pas de vrai réseau, pas de vrai accès disque, sauf hook explicite de ma part. Avant toute exécution du payload réel, tous les syscalls réseau (`connect`, `sendto`, `send`, `bind`, `recvfrom`, `recv`) ont été explicitement remplacés par des gestionnaires qui journalisent et refusent (`ql.os.set_api(...)`), car Qiling laisse par défaut passer de vraies connexions AF_INET vers le système hôte, point de sécurité vérifié et neutralisé avant tout test sur le payload réel (validé d'abord sur un binaire de test inoffensif du rootfs Qiling).

**Cible retenue : le binaire Linux**, et pas le PE Windows. Pour émuler correctement l'API Win32, Qiling a besoin des vraies DLL système de Windows (`kernel32.dll`, `ntdll.dll`...), des binaires propriétaires de Microsoft, non redistribuables, qu'on n'a pas sans une vraie installation Windows. La cible Linux ne dépend que de bibliothèques libres (glibc, libbpf, libelf...), déjà présentes sur la machine d'analyse.

**Découverte en cours de route** : la première tentative d'émulation a échoué avec `error while loading shared libraries: libbpf.so.1: cannot open shared object file`. Vérifié statiquement (`readelf -d binaries/payload_linux`) : le binaire déclare bien **`libbpf.so.1`** comme dépendance obligatoire (avec `libm.so.6`, `libc.so.6`). Ça confirme, indépendamment et directement depuis le binaire (pas seulement via un rapport tiers), la capacité de rootkit eBPF documentée par [Phoenix Security](https://phoenix.security/ironworm-npm-supply-chain-worm-rust-ebpf-rootkit-tor/).

**Cascade de blocages en essayant de faire avancer l'émulation** (chaque correctif révélant un nouveau blocage différent, pas le même en boucle) :
1. `libbpf.so.1` manquant → copié depuis le système hôte (bibliothèque libre, déjà installée)
2. `libbpf.so.1` dépend elle-même de `libelf.so.1` → copié depuis le système hôte
3. `libelf.so.1` dépend de `libz.so.1` et `libzstd.so.1` → copiés depuis le système hôte
4. Toutes les dépendances trouvées, mais **conflit de version glibc** : les bibliothèques système copiées (compilées contre une glibc moderne) sont incompatibles avec la `libc.so.6`/`ld-linux-x86-64.so.2` bundlées dans le rootfs de test Qiling (plus ancienne)
5. `libc.so.6`/`ld-linux-x86-64.so.2` du système hôte copiées à leur tour pour cohérence → le **vrai** linker dynamique système s'attend alors à un vrai `/etc/ld.so.cache` et une configuration système de résolution de chemins qui n'existent pas dans le rootfs virtuel de Qiling, et échoue à retrouver les bibliothèques pourtant présentes

**Décision** : j'arrête là. Continuer, ce serait empiler des correctifs sur un problème d'ingénierie système (reconstruire un environnement `ld.so` cohérent dans le rootfs virtuel) qui n'a plus grand-chose à voir avec la rétro-ingénierie du malware, et dont l'issue n'était pas garantie dans un délai raisonnable. Je le note comme une limite assumée.

**Bilan de cette tentative** : je n'ai pas déchiffré le blob de mon échantillon. Mais la tentative a donné une confirmation indépendante solide (la dépendance `libbpf`, vérifiée en statique) qui recoupe le rapport de Phoenix Security, en plus de la confirmation par JFrog du mécanisme C2 sur Tor (§7.4). Mis ensemble, ça donne une image cohérente du C2 (sur Tor, donc hors de portée d'un simple signalement à l'hébergeur), sans avoir eu à le déchiffrer moi-même.

## 8. Attribution, self-dependency et règles YARA

### Attribution : rien de concluant, mais une discipline notable

J'ai cherché des fuites OPSEC (chemins de build, identifiants, résidus de compilation) dans les 3 binaires et dans les tarballs :

- **Aucun chemin `.rs` trouvé** dans les 3 binaires (`grep -E "\.rs$"` sur les chaînes extraites : 0 résultat partout). Un binaire Rust standard laisse presque toujours fuiter des chemins de fichiers source dans les messages de panic (`/home/<user>/projet/src/main.rs:42`) sauf compilation avec `--remap-path-prefix` ou équivalent. Leur absence totale ici est elle-même un signal : discipline de build délibérée, pas un oubli.
- **Seuls emails trouvés** : `<appro@openssl.org>` (Andy Polyakov), crédit légitime des routines assembleur CRYPTOGAMS présentes dans quasiment toute bibliothèque crypto Rust compilée (`ring` et dérivés), sans lien avec l'attaquant.
- **`_npmUser` du registre** confirme la publication depuis le compte légitime `jscrambler_` / `info@jscrambler.com`, pas un compte tiers, ce qui colle avec la théorie "credential de publication volé" déjà posée en §1, pas une nouvelle fuite d'identité.
- **Aucun résidu de build** (`.git`, `.DS_Store`, `__MACOSX`, fichiers de test) dans le tarball 8.18.0.

**Conclusion** : aucune piste d'attribution. L'absence de fuite correspond à un opérateur qui maîtrise son outillage. Ce n'est pas un signal négatif en soi.

### La "self-dependency" est confirmée et localisée

safedep mentionnait une « self-dependency » sur la 8.20.0, sans détailler. En comparant les `dependencies` de `package.json` d'une version à l'autre, on la trouve directement sur la **8.18.0**, que j'ai en main (pas besoin de la version manquante) :

```json
"dependencies": {
  ...
  "jscrambler": "^8.17.0",
  ...
}
```

Cette ligne est absente de 8.14.0, 8.15.0 et 8.22.0 (j'ai vérifié le diff exact : rien d'autre ne change dans le bloc `dependencies`). La clé `jscrambler` est à sa place alphabétique dans la liste (entre `https-proxy-agent` et `jszip`). C'est une insertion propre, faite par un outil, pas un ajout bâclé à la main.

**Hypothèse fonctionnelle (non vérifiée par exécution, `node`/`npm`/`pnpm` indisponibles sur cette machine, et de toute façon hors de question d'exécuter le paquet réel)** : sous npm classique, `8.18.0` satisfait déjà l'intervalle `^8.17.0` qu'il se déclare à lui-même, la résolution de dépendances standard dédupliquerait normalement sans installer de copie imbriquée, ce qui rendrait cette ligne inerte pour un simple `npm install`. Mais safedep précise que **8.17.0** (pas 8.16.0, pas 8.20.0) est la version qui contourne spécifiquement l'allowlist de scripts de **pnpm** (§3.4). Une lecture cohérente : épingler `^8.17.0` comme self-dependency pourrait forcer certains résolveurs non-plats (pnpm en mode strict, potentiellement Yarn PnP) à installer une copie imbriquée distincte de 8.17.0 plutôt que de simplement dédupliquer vers le parent, ce qui déclencherait son hook `preinstall` (qui contourne justement la protection pnpm) même chez un utilisateur ayant obtenu la version 8.18.0 "propre en apparence" (sans hook `preinstall` visible dans son propre `package.json`). Ce serait un chemin d'infection redondant délibéré : la version qui a l'air la plus safe embarquerait, via cette dépendance, exactement la version conçue pour contourner la protection du gestionnaire de paquets le plus restrictif.

**Question ouverte : l'hypothèse colle avec les faits, mais elle n'est pas vérifiée.** Vérifier nécessiterait soit un environnement Node/pnpm jetable pour un `install --ignore-scripts --package-lock-only` (résolution de dépendances sans jamais exécuter de code, méthode standard chez les chercheurs en sécurité npm), soit de récupérer enfin 8.17.0/8.20.0 pour lire leur `package.json`/JS directement.

### Règles YARA : testées, zéro faux positif sur les versions saines

Trois règles dans [`tools/ironworm.yar`](writeups/jscrambler-npm-supplychain/tools/ironworm.yar), validées avec `yara -r` :

| Règle | Détecte | Testée sur |
|---|---|---|
| `NPM_jscrambler_IronWorm_container` | Le magic `\x1bCSI\x01` du conteneur `intro.js` | Match sur `intro.js` (8.18.0) |
| `NPM_jscrambler_IronWorm_js_loader` | La logique JS de lecture du conteneur + spawn détaché (`preinstall` **et** IIFE runtime) | Match sur `dist/setup.js` (8.14.0) et `dist/index.js`/`dist/bin/jscrambler.js` (8.18.0) |
| `IronWorm_Rust_infostealer_payload` | Le payload natif Rust (combinaison seed-phrase + EC2 IMDS + protocole JSON C2 + Steam, choisie pour être improbable dans un logiciel légitime) | Match sur les 3 binaires (Linux/Windows/macOS) |

**Validation négative** : un scan récursif de toute l'arborescence de 8.22.0 (saine) et de 8.15.0 (correctif) ne donne aucun match, pour aucune des 3 règles. Le scan de 8.18.0 (malveillante) matche exactement les 2 fichiers JS infectés et le conteneur, rien d'autre.

## 9. Limites

- **C2 localisé et caractérisé indépendamment (§7.1), mais jamais déchiffré.** Trois approches ont échoué : recherche de chaînes ciblée (§7.1), désassemblage + décompilation à travers 8+ fonctions crypto génériques sans atteindre la passphrase (§7.2-7.3), émulation dynamique bloquée par une cascade de problèmes d'ingénierie système sans rapport avec le malware lui-même (§7.5, reprise non jugée prioritaire : le C2 est un service caché Tor, non actionnable par un signalement d'hébergeur classique). Le C2 est néanmoins documenté par recoupement externe fiable (JFrog, §7.4) : service caché Tor, protocole HTTP/JSON, 3 commandes (upload credentials, drop de fichiers, shell distant).
- **Rootkit eBPF** : dépendance `libbpf.so.1` confirmée statiquement (`readelf -d`) sur le binaire Linux, corrobore directement, avec des outils indépendants, la capacité de rootkit eBPF documentée par Phoenix Security, sans avoir eu besoin de faire fonctionner l'émulation jusqu'au bout.
- **Un faux positif `capa`** : « linked against wolfSSL », contredit par l'absence de chaînes wolfSSL et la présence de rustls (§5.2, §6).
- 3 des 5 versions malveillantes (8.16.0/8.17.0/8.20.0) restent non récupérées après 5 canaux testés ; leur mécanisme exact repose sur une seule source tierce (safedep/OSV), non revérifié indépendamment.
- **La self-dependency (§8) est une hypothèse non vérifiée par exécution** : `node`/`npm`/`pnpm` indisponibles sur cette machine pour un test de résolution en dry-run (`install --ignore-scripts --package-lock-only`). L'explication proposée (redondance de livraison ciblant spécifiquement les protections pnpm) colle avec les faits connus mais reste une lecture, pas une preuve.
- **Les IP C2 citées par JFrog (`37.27.122.124`, `57.128.246.79`) ne sont vraisemblablement pas l'infrastructure de l'attaquant**, la première est un relais Tor public confirmé par Shodan, la seconde non identifiable. Aucune adresse `.onion` réelle n'a été obtenue.
- L'attribution Rust du binaire macOS n'a aucune confirmation directe (ni chaîne `rustc`, ni bibliothèque Rust identifiée dessus à ce stade), supposée par cohérence de campagne uniquement.
- **Persistance Linux non identifiée.** C'est signalé comme une inconnue dans `remediation.md`.
- Le mécanisme d'auto-propagation (scan de tokens npm, sélection de packages, republication) est mentionné par JFrog mais n'a pas été vérifié directement : absent du JS (§8), vraisemblablement dans le binaire natif, non tracé.
- La capacité `capa` "interact with driver via IOCTL" (§5.2) reste de signification inconnue.
- Aucune exécution réelle du malware n'a eu lieu à aucun moment de cette investigation, sur aucune plateforme : la confirmation du comportement dynamique (C2 Tor, exfiltration) s'appuie exclusivement sur le rapport JFrog (§7.4), jamais sur une exécution ou une émulation aboutie ici.

## 10. IOCs consolidés

| Type | Valeur |
|---|---|
| Package npm compromis | `jscrambler` |
| Versions malveillantes | 8.14.0, 8.16.0*, 8.17.0*, 8.18.0, 8.20.0* (*comportement connu par source tierce uniquement) |
| Version(s) saine(s) | 8.13.0 (dernière avant compromission), 8.15.0 (correctif temporaire), 8.22.0 (correctif définitif) |
| SHA256 `dist/setup.js` (8.14.0) | `a742de963f14a92d24ebcbc7b44ac867e23a20d31d1b0094a13a4f83287f4e60` |
| SHA256 `dist/intro.js` (identique sur toutes les versions malveillantes) | `a41a523ef9517aab37ed6eea0ec881821bdcb7aefcb5c5f603adc7907f868c86` |
| SHA256 `dist/index.js` (8.14.0/8.15.0/8.22.0, identiques) | `5d39319f16f482289bd15daf33750c56ca75e4014a0f3a3f637247075874e0af` |
| SHA256 `dist/index.js` (8.18.0, IIFE injectée) | `00d64b49db55cf814f2ba74e7b16b9e9c779e22efc965962d7372841d536f929` |
| SHA256 tarball npm `jscrambler-8.18.0.tgz` | `c192578173b89a439ecbab3bcb91b205227c225c8009a6fb7e9c13f226b74899` |
| SHA256 payload Linux (décompressé) | `fbbcf4d8f98168f78f5c0c47a9ae56d59ec8ac84a7c9ca6b797fedfb8d62d2bd` |
| SHA256 payload Windows (décompressé) | `b7ca95d1b23c8e67416a25cedf741de0917c2096bbc9d24649eea7853d054903` |
| SHA256 payload macOS (décompressé) | `c8fd47d36bdf7c825378593ab82ed8c24d1dc52e26b507812393e24e1d5201fd` |
| Magic conteneur `intro.js` | `1b 43 53 49 01` |
| Config C2 (chiffrée, jamais déchiffrée ici) | PE `.rdata`, offset `0x326400`, ~80 Ko, AES-NI + PBKDF2/scrypt |
| Protocole RAT (confirmé en chaîne statique) | JSON `{"t":"out"/"done","id":"...","progress":true}` |
| C2 réel (source tierce, JFrog) | Service caché Tor, beacon HTTP vers `/api/agent` |
| IP citées par JFrog (probablement infra Tor, pas l'attaquant) | `37.27.122.124` (relais Tor confirmé, `torrelay-1.csguard.eu`), `57.128.246.79` (non identifiable) |
| Fallback d'exfiltration (source tierce, JFrog) | Upload vers `temp.sh` via Tor |
| Dépendance eBPF confirmée statiquement | `libbpf.so.1` (`readelf -d`, binaire Linux) |
| Famille (attribution tierce) | IronWorm (lignée Shai-Hulud) |
| Advisories | OSV `MAL-2026-10187`, GHSA `GHSA-v3g3-4x77-rw68` |

## 11. Reproduire l'analyse

Le journal détaillé (commande, raison, sortie brute) est dans [`runbook.md`](writeups/jscrambler-npm-supplychain/runbook.md). Outils : [`tools/extract_intro_payloads.py`](writeups/jscrambler-npm-supplychain/tools/extract_intro_payloads.py), [`tools/ironworm.yar`](writeups/jscrambler-npm-supplychain/tools/ironworm.yar). Le guide pratique de détection et de nettoyage pour un poste concerné : [`remediation.md`](writeups/jscrambler-npm-supplychain/remediation.md).
