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

## A. Recherche et acquisition des échantillons

```bash
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.

```bash
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")

```bash
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é :

```bash
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

```bash
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é.

```bash
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.

```bash
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 :
```bash
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) :
```bash
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) :
```bash
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) :

```bash
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.
