Runbook : reproduction pas à pas (NightSpire, réutilisation de keystream AES-CTR)

A. Recherche et acquisition des échantillons#

KEY=$(cat .mb_api_key)
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
  -d "query=get_taginfo&tag=NightSpire&limit=1000" -o /tmp/nightspire_tag.json

7 échantillons trouvés au total (tags NightSpire et Nightspire, casse différente selon le lot), deux dates de première soumission : six le 2025-06-30, un le 2026-01-31 (c5f526cc... partage la même date que 2c3f8889...). Téléchargés dans binaries/ via get_file pour chaque hash.

Triage : tous PE32+ x86-64, une seule DLL importée (liaison statique), TimeDateStamp=0. Chaînes confirmant Go 1.24.1 (go1.24.1, runtime.main) et l'identité NightSpire réelle (NightSpire, nightspireteam.receiver@onionmail.org, main.MakeReadMeFile).

Deux échantillons (e275b8a02bf23b565bdaabadb220b39409eddc6b8253eb04e0f092d697e3b53d, 32e10dc9fe935d7c835530be214142041b6aa25ee32c62648dea124401137ea5) ont des symboles de debug Go non stripés : liste main.* extraite par strings avant toute décompilation, confirmant un module de chiffrement isolable (main.EncryptFilev1/v2, main.generateSalt, main.EncryptRSA, main.GenerateRandomKey/Nonce).

B. Environnement : venv, Ghidra, vérification réelle avant de se lancer#

Le dépôt vit sur une clé USB exFAT ; le venv Python (~/.venvs/ctf-reverse) est hors clé, sur disque local, donc non affecté par les limitations exFAT (pas de symlinks/permissions POSIX nécessaires pour un venv qui ne touche pas cette partition).

Avant toute décompilation, test réel (pas juste import pyghidra) : lancement de la JVM PyGhidra et analyse complète du binaire candidat.

source ~/.venvs/ctf-reverse/bin/activate
python tools/list_functions.py binaries/e275b8a0....exe /tmp/test.txt main.
# DONE, 23/2347 fonctions matchees, ~2m19s (premiere analyse, cache Ghidra ensuite)

Scripts copiés depuis une analyse Go précédente du dépôt (list_functions.py, decompile_by_name.py, decompile_addr.py, find_api_callers.py), fonctionnels tels quels sur ce binaire Go 1.24.1.

C. Décompilation ciblée de e275b8a0 (lignée "Encrypter v2.1")#

python tools/decompile_by_name.py binaries/e275b8a0....exe tools/decomp_main_e275.txt \
  main.GenerateRandomKey main.GenerateRandomNonce main.EncryptRSA main.bytesToPublicKey \
  main.generateSalt main.DecorateSalt main.generateSignValue main.EncryptFilev1 \
  main.EncryptFilev2 main.encryptMethod main.checkPossibility main.AddedSpecifier \
  main.EncryptDir main.main main.MakeReadMeFile

Lecture du résultat : génération clé/nonce saine (crypto/rand.Reader), RSA-OAEP-SHA512 correcte pour la clé de session (voir nightspire-ransomware.md §2). Dans main.EncryptFilev2, main.generateSalt() n'est appelé qu'une seule fois avant la boucle while (chunk < 0x400), et le même salt est repassé à main.encryptMethod à chaque itération : le bug de réutilisation de keystream AES-CTR entre chunks du même fichier (voir nightspire-ransomware.md §3).

D. Extraction de l'ABI réelle pour l'émulation (dump_calling_convention.py)#

Go 1.24 utilise un ABI interne par registres, pas les conventions System V/Windows classiques. Plutôt que de deviner l'ordre des registres depuis le désassemblage, script tools/dump_calling_convention.py interrogeant directement l'API Ghidra (Function.getParameters(), VariableStorage) pour obtenir le stockage réel de chaque paramètre, tel que récupéré depuis le DWARF de l'échantillon non stripé :

python tools/dump_calling_convention.py binaries/e275b8a0....exe main.encryptMethod \
  crypto/aes.NewCipher crypto/cipher.NewCTR

Ordre des varnodes retourné par Ghidra en ordre INVERSÉ par rapport à la déclaration des champs (array/len/cap) : vérifié empiriquement par désassemblage brut (capstone) du prologue de main.encryptMethod (cmp rsp,[r14+0x10] puis mouvements de registres vers la pile), confirmant content: array=RAX len=RBX cap=RCX, salt: array=RDI len=RSI cap=R8.

E. Construction du harnais Unicorn : cinq obstacles runtime rencontrés en cascade#

Objectif : appeler directement main.encryptMethod (donc le vrai crypto/aes.NewCipher/crypto/cipher.NewCTR/XORKeyStream compilés) via Unicorn, sans exécuter la moindre fonction init() de Go (saut direct dans encryptMethod).

Prologue neutralisé en pointant R14 (registre "g courant" de l'ABI interne Go) vers un faux g avec stackguard0=0 (rsp réel toujours > 0), évitant runtime.morestack.

Chaque obstacle suivant a été découvert en laissant l'émulation planter, puis en retrouvant la fonction contenante de l'adresse fautive via un script dédié (tools/identify_addr.py, interroge FunctionManager.getFunctionContaining + la table de symboles Ghidra) plutôt qu'en devinant depuis le désassemblage :

  1. runtime.stdcall (0x431ec0) : plante en écrivant à une adresse quasi nulle (g.m jamais initialisé), puis dans asmcgocall avec un pointeur de fonction nul (runtime.asmstdcallAddr, rempli seulement par runtime.osinit() au vrai démarrage). Identifié via identify_addr.py sur l'adresse de retour trouvée : fonction contenante runtime.stdcall, référence croisée vers runtime.asmstdcallAddr. Neutralisée en forçant le retour à 0.
  2. runtime.newobject/runtime.makeslice : ABI extraite via dump_calling_convention.py (typ/et en RAX, retour en RAX pour newobject ; et/len/cap en RAX/RBX/RCX pour makeslice), redirigées vers un allocateur "bump" Python.
  3. runtime.mallocgc : le panic "runtime: panic before malloc heap initialized" persistait malgré (1)-(2) ; recherche des fonctions d'allocation disponibles (list_functions.py ... mallocgc newarray newobject makeslice growslice) puis hook générique sur runtime.mallocgc (0x40cba0, ABI : size=RAX, typ=RBX, needzero=CL).
  4. runtime.growslice : même panic encore observé après (3) ; growslice a sa propre copie inlinée de la vérification (le compilateur Go duplique les petites fonctions au lieu de les partager). Hook dédié (0x447180) avec copie des données existantes préservée (oldPtr/oldCap non nuls).
  5. runtime.persistentalloc1 (0x40d9e0) : cause réelle et définitive du dernier blocage, confirmée en hookant temporairement runtime.throw/runtime.gopanic/la famille runtime.goPanicXxx pour lire directement le message du premier panic authentique (pas un printf en cascade) : "runtime: cannot allocate memory", provenant d'une tentative de réservation mémoire OS réelle (VirtualAlloc), inexistante sous Unicorn pur. Identifiée via identify_addr.py sur l'adresse de l'appel à throw. Redirigée vers le même allocateur bump.

Script complet : tools/emulate_encryptmethod_reuse_poc.py. Résultat : deux plaintexts chiffrés avec le même salt produisent des ciphertexts dont le XOR égale exactement le XOR des plaintexts (voir nightspire-ransomware.md §4).

F. Vérification sur les 6 autres échantillons#

python tools/decompile_by_name.py binaries/32e10dc9....exe tools/decomp_main_32e1.txt \
  main.EncryptFilev2 main.generateSalt main.encryptMethod

32e10dc9... : structure de main.EncryptFilev2 identique à l'octet près à e275b8a0.... Même lignée "Encrypter v2.1", bug confirmé.

python tools/list_functions.py binaries/4cda6af6....exe tools/funcs_4cda.txt main.

Résultat inattendu : pas de main.EncryptFilev1/v2, mais main.startEncrypting/main.EncryptFile/main.getEncryptionType : lignée de code différente ("ImmoRan", chemin source C:/Users/Administrator/.../Encrypter/New folder/ImmoRan/main.go, contre C:/Users/Senko/.../Encrypter v2.1/main.go), malgré la même date de première soumission.

python tools/decompile_by_name.py binaries/4cda6af6....exe tools/decomp_main_4cda.txt \
  main.startEncrypting main.EncryptFile main.generateSalt main.encryptMethod main.getEncryptionType

Même défaut dans une architecture différente : main_generateSalt() appelé une fois en tête de startEncrypting, réutilisé sur 4 à 6 appels main.EncryptFile à des offsets différents selon le type de chiffrement retourné par getEncryptionType (voir nightspire-ransomware.md §5).

Vérification du build le plus récent :

python tools/list_functions.py binaries/2c3f8889....exe tools/funcs_2c3f.txt main.
# meme lignee "ImmoRan" (startEncrypting/EncryptFile/getEncryptionType)
python tools/decompile_by_name.py binaries/2c3f8889....exe tools/decomp_main_2c3f.txt \
  main.startEncrypting main.EncryptFile

2c3f8889... (2026-01-31, sept mois après les premiers échantillons) : structure quasi identique (startEncrypting 1995 octets contre 2009 pour 4cda6af6...), même schéma de réutilisation. Non corrigé sur le build le plus récent connu.

Vérification rapide des trois échantillons restants (présence de fonctions, sans décompilation complète d'abord) :

for f in c2859adc... d5f9595a... c5f526cc...; do
  python tools/list_functions.py binaries/$f.exe tools/lineage_$f.txt main.startEncrypting main.EncryptFilev2
done

Les trois correspondent à main.startEncrypting (lignée "ImmoRan"), pas à main.EncryptFilev2, même si les tailles de fichier ressemblent en surface à celles de la lignée "Encrypter v2.1". Décompilation complète de c2859adc... pour confirmer (d5f9595a.../c5f526cc... ont une taille de fonction startEncrypting identique à l'octet près à c2859adc.../2c3f8889... respectivement, non redécompilés individuellement en détail) :

python tools/decompile_by_name.py binaries/c2859adc....exe tools/decomp_main_c2859adc.txt main.startEncrypting

Même schéma confirmé : main_generateSalt() une fois, 6 appels main.EncryptFile.

Répartition finale : 2 échantillons "Encrypter v2.1" (e275b8a0..., 32e10dc9...), 5 échantillons "ImmoRan" (4cda6af6..., c2859adc..., d5f9595a..., 2c3f8889..., c5f526cc...). Les 7 échantillons connus sont vulnérables.

G. Preuve de concept et outil de récupération pratique#

tools/nightspire_keystream_reuse_poc.py : démonstration synthétique avec cryptography (AES-256-CTR, déjà validée bit-exact en §E contre le vrai binaire), scénario chunk zéro-rempli + chunk secret.

tools/nightspire_recover_chunk.py : outil pratique opérant sur un vrai fichier chiffré par chunks (pas une démonstration en mémoire), testé sur un fichier synthétique de 4 chunks (1 zéro-rempli, 2 aléatoires servant de témoin négatif, 1 contenant un secret) :

python tools/nightspire_recover_chunk.py fake_nightspire.enc --known-offset 0 --known-zero 200

Secret du chunk 3 intégralement récupéré ; chunks 1 et 2 (réellement aléatoires) produisent du bruit, confirmant l'absence de faux positif.

H. Rédaction#

nightspire-ransomware.md, README.md, remediation.md et une règle YARA distinguant les deux lignées ("Encrypter v2.1" et "ImmoRan") rédigés à partir des constats ci-dessus.