# NightSpire : le même keystream AES-CTR pour tous les chunks d'un fichier

**Catégorie :** Ransomware

**Analyste :** Gordon PEIRS ([@ozer0x777](https://github.com/Ozer0x777))

**Famille :** NightSpire, ransomware repéré sur MalwareBazaar (tag `NightSpire`/`Nightspire`), écrit en Go 1.24.1, ciblant Windows.

**Méthode :** reverse statique (Ghidra headless via PyGhidra, dont le support natif des binaires Go redonne les noms de fonctions complets `paquet.Fonction`), puis validation dynamique au bit près : Unicorn Engine (CPU seul, aucun appel système transmis) exécute directement le vrai code machine de la routine de chiffrement, sans aucune réimplémentation de l'AES. Les échantillons ne sont jamais exécutés pour de vrai.

---

## Résumé

La clé de session AES-256 et l'IV CTR (`main.GenerateRandomKey`/`main.GenerateRandomNonce`) viennent de `crypto/rand.Reader`, le CSPRNG du système : rien à redire. La protection de cette clé de session par RSA (`main.EncryptRSA`) utilise OAEP avec SHA-512, une construction standard et bien employée.

Le défaut est ailleurs. Sur les gros fichiers, NightSpire découpe le contenu en chunks (1 Mio, ou plus selon le schéma de chiffrement intermittent appliqué aux très gros fichiers), mais il **génère la paire clé+IV une seule fois par fichier, avant la boucle de chunks, et la réutilise pour chaque chunk**. Comme le mode CTR repart d'un compteur à zéro à chaque appel de la routine de chiffrement, **tous les chunks d'un même fichier partagent exactement le même keystream AES-CTR, octet pour octet**. Le XOR de deux chunks chiffrés d'un même fichier donne directement `clair_A XOR clair_B`, sans la clé privée RSA de l'attaquant. C'est la réutilisation de flux classique, le « two-time pad ».

J'ai confirmé ce défaut de trois façons indépendantes :

1. **En lisant le code décompilé** de l'échantillon `e275b8a0...` (symboles Go non strippés) : `main.generateSalt()` est appelé une seule fois avant la boucle `while (chunk < 0x400)` de `main.EncryptFilev2`, et le même `salt` resservi à chaque itération.
2. **En émulant au bit près le vrai code machine** (§4) : j'appelle directement dans Unicorn `main.encryptMethod` (donc les vrais `crypto/aes.NewCipher` + `crypto/cipher.NewCTR` + `XORKeyStream` du binaire) sur deux contenus différents avec le même salt. Le XOR des deux chiffrés obtenus est exactement égal au XOR des deux clairs d'origine. C'est une preuve par le calcul, pas une lecture de pseudocode.
3. **Sur les 7 échantillons connus**, répartis en **deux lignées de code indépendantes** (chemins source embarqués distincts, architectures de chiffrement différentes) : le défaut est dans les deux, et il **persiste sans correction jusqu'au build le plus récent connu (2026-01-31)**.

> Tout est reproductible depuis [`runbook.md`](writeups/nightspire-ransomware/runbook.md). Les scripts sont dans [`tools/`](tools.html?slug=nightspire-ransomware).

## 1. Échantillons

| SHA256 | Première soumission | Taille | Lignée de code | Symboles Go |
|---|---|---|---|---|
| `e275b8a02bf23b565bdaabadb220b39409eddc6b8253eb04e0f092d697e3b53d` | 2025-06-30 | 3 504 640 o | "Encrypter v2.1" (`main.EncryptFilev1`/`v2`) | non stripés |
| `32e10dc9fe935d7c835530be214142041b6aa25ee32c62648dea124401137ea5` | 2025-06-30 | 4 414 464 o | "Encrypter v2.1" | non stripés |
| `4cda6af63873eca99b9a0b69be1c8b9c81b501339f569a30fd30394fa54da3e1` | 2025-06-30 | 2 617 344 o | "ImmoRan" (`main.startEncrypting`/`EncryptFile`) | pclntab uniquement |
| `c2859adc342cbcc36485ef6c3020913c459079acbeae3456b44297d7704f6853` | 2025-06-30 | 2 989 568 o | "ImmoRan" | pclntab uniquement |
| `d5f9595abb54947a6b0f8a55428ca95e6402d2aeb72cbc109beca457555a99a6` | 2025-06-30 | 2 989 568 o | "ImmoRan" | pclntab uniquement |
| `2c3f8889c9f5d765632ad91b4cc2a852c54845e143dbd2b68359b6f9cdfd8f99` | 2026-01-31 | 2 987 520 o | "ImmoRan" | pclntab uniquement |
| `c5f526cc62688cf34c49d098dab81e24e4294f832ada57433ef505d5ac6da8f3` | 2026-01-31 | 2 999 808 o | "ImmoRan" | pclntab uniquement |

Ils viennent de MalwareBazaar (tag `NightSpire`/`Nightspire`, la casse varie selon le lot). J'ai pris tous les échantillons tagués disponibles au moment de la recherche. Ce sont tous des PE32+ x86-64 avec une seule DLL importée (liaison statique Go), `TimeDateStamp=0`, en Go 1.24.1 (chaînes `go1.24.1`, `runtime.main`).

C'est bien le ransomware NightSpire et pas un faux positif de tag : on y trouve les chaînes `NightSpire`, le contact de rançon `nightspireteam.receiver@onionmail.org` et la fonction `main.MakeReadMeFile`.

**Deux lignées de code indépendantes, qui coexistent à la même date.** Les six premiers échantillons ont été soumis le même jour (2025-06-30), mais `e275b8a0.../32e10dc9...` et le groupe `4cda6af6.../c2859adc.../d5f9595a...` viennent de deux bases de code distinctes. Les chemins source embarqués diffèrent (`C:/Users/Senko/Documents/Dev_Pack/Applications/Encrypter/Encrypter v2.1/main.go` contre `C:/Users/Administrator/Documents/Dev_Pack/Encrypter/New folder/ImmoRan/main.go`), les fonctions sont renommées (`EncryptFilev1`/`EncryptFilev2` contre `startEncrypting`/`EncryptFile`/`getEncryptionType`) et l'architecture de chiffrement n'est pas la même (découpage séquentiel simple contre chiffrement intermittent par offsets calculés selon la taille du fichier). Le nom de projet « ImmoRan » suggère peut-être une base de code partagée avec une autre famille de ransomware ; je n'ai pas creusé ce point.

Malgré ces différences, aucun des sept échantillons connus n'échappe au défaut décrit ici (voir §3 et §5).

Les binaires dits « strippés » dans le triage MalwareBazaar (pas de symboles de debug DWARF/PDB) gardent en réalité la pclntab de Go. C'est une structure du runtime, distincte, qui sert à reconstituer la pile d'appels et qui est presque toujours là, sauf strip explicite et agressif. Ghidra retrouve donc les noms de fonctions complets (`main.startEncrypting`, etc.) sur les cinq échantillons « ImmoRan », sans symboles de debug.

## 2. Génération de clé, IV, protection RSA : rien d'anormal

`main.GenerateRandomKey` (32 octets) et `main.GenerateRandomNonce` (16 octets) passent tous les deux par `crypto/rand.Reader` via `io.ReadAtLeast` :

```c
__uint8 main::main_GenerateRandomKey(void)
{
  ...
  _Var1 = runtime::runtime_makeslice(&uint8___Uint8_type,0x20,0x20);
  r.data = crypto_rand_Reader.data;
  r.tab = (io_Reader_itab *)crypto_rand_Reader.tab;
  mVar2 = io::io_ReadAtLeast(r,buf,0x20);
  ...
}
```

`crypto/rand.Reader` est le CSPRNG du système (`BCryptGenRandom` sous Windows, via le paquet standard Go). Pas de repli sur `math/rand` ni de seed temporelle, contrairement à d'autres familles de ransomware déjà documentées.

`main.EncryptRSA` chiffre un message avec `crypto/rsa.EncryptOAEP`, hash SHA-512, et le même `crypto_rand.Reader` comme source d'aléa pour le padding OAEP :

```c
mVar6 = crypto/rsa::crypto_rsa_EncryptOAEP(hash,random,(crypto_rsa_PublicKey *)publicKey,(__byte)message,label);
```

RSA-OAEP est une construction moderne et sûre pour protéger une petite quantité de données sous une clé publique (ici la clé de session AES-256 et l'IV, concaténés par `main.generateSalt`, voir §3). Rien à signaler sur cette chaîne.

## 3. Le défaut : le salt est généré une fois par fichier, réutilisé sur tous ses chunks

`main.generateSalt` se contente de concaténer la clé (32 o) et le nonce (16 o), soit 48 octets :

```c
_Var2 = main_GenerateRandomKey();
_Var3 = main_GenerateRandomNonce();
sVar1 = runtime::runtime_slicebytetostring(&local_60,local_18,_Var2.len);
a1    = runtime::runtime_slicebytetostring(&local_80,local_20,_Var3.len);
sVar1 = runtime::runtime_concatstring2(&local_40,sVar1,a1);
```

`main.encryptMethod(content, salt)` utilise ces 48 octets tels quels : clé AES-256 (`salt[:32]`) et IV CTR (`salt[32:48]`) :

```c
__uint8 main::main_encryptMethod(__uint8 content,__uint8 salt)
{
  key.len = 0x20;
  key.array = salt.array;                        // salt[:32] = cle AES-256
  mVar6 = crypto/aes::crypto_aes_NewCipher(key);
  iv.len = uVar1 - 0x20;
  iv.array = salt.array + 0x20;                   // salt[32:] = IV CTR
  cVar3 = crypto/cipher::crypto_cipher_NewCTR(mVar6.~r0,iv);
  (*local_20->XORKeyStream)(local_18,in_stack_ffffffffffffffa8,in_stack_ffffffffffffffc0);
  return _Var5;
}
```

C'est de l'AES-256-CTR standard, et un appel isolé n'a rien d'anormal. Le problème est chez l'appelant, `main.EncryptFilev2` (lignée « Encrypter v2.1 », le chemin pris pour les gros fichiers : `.iso`, `.vhdx`, `.vmdk`, `.zip`, `.vib`, `.kab`, `.mdf`, `.flt`, `.mdl`, d'après le dispatch de `main.EncryptDir`) :

```c
_Var14 = main_generateSalt();            // UNE SEULE FOIS, avant la boucle
...
while (iVar7 < 0x400) {                  // jusqu'a 1024 chunks de 1 Mio (1 Go)
  _Var15 = runtime::runtime_makeslice(&uint8___Uint8_type,uVar3,uVar3);
  mVar16 = os::os___File__ReadAt(local_58);          // lit le chunk courant
  salt.array = local_78;  salt.len = local_f8;  salt.cap = local_f0;  // TOUJOURS le meme salt
  _Var17 = main_encryptMethod(_Var17,salt);
  os::os___File__Write(local_58,_Var15);
  ...
}
```

`main.generateSalt()` n'est appelé **qu'une seule fois, avant la boucle**. À chaque itération, le même `salt` (donc la même clé et le même IV) est repassé à `main.encryptMethod`. Et comme `crypto/cipher.NewCTR` initialise toujours le compteur de bloc à zéro par rapport à l'IV fourni, **le chunk 0 et le chunk 5 d'un même fichier, par exemple, sont chiffrés avec exactement le même keystream, du premier au dernier octet**.

Donc, pour deux chunks quelconques d'un même fichier, `chiffré_A XOR chiffré_B = clair_A XOR clair_B`. Si un attaquant connaît ou devine le clair d'un chunk, il récupère le keystream à cette position, et ce keystream vaut pour **tous les autres chunks du fichier à la même position relative**, sans toucher à la clé privée RSA. Deviner n'est pas difficile : les plages remplies de zéros sont très fréquentes dans les formats visés (image disque de VM sparse, ISO paddé, sauvegarde avec des zones non allouées).

`main.EncryptFilev1`, utilisé pour les autres extensions, n'a pas ce défaut puisqu'il n'appelle `main.encryptMethod` qu'une fois par fichier. En revanche, il ne chiffre que le premier mégaoctet des fichiers de plus de 1 Mio (le reste n'est ni lu ni écrit). C'est une autre limitation, qui sort du cadre de ce document.

## 4. Confirmation bit-exacte par émulation du vrai code machine

Le pseudocode de Ghidra peut, en théorie, contenir des artefacts de décompilation. Pour ne pas m'y fier uniquement, j'ai validé le mécanisme du §3 en exécutant directement dans Unicorn Engine le vrai code machine x86-64 de `main.encryptMethod`, extrait du binaire `e275b8a0...`.

**L'ABI vient du DWARF**, je ne l'ai pas devinée. Go 1.24 utilise un ABI interne par registres, différent des conventions System V et Windows :

```
main.encryptMethod(content []uint8, salt []uint8) []uint8
  content : array=RAX  len=RBX  cap=RCX
  salt    : array=RDI  len=RSI  cap=R8
  retour  : array=RAX  len=RBX  cap=RCX
```

Le prologue Go vérifie la croissance de la pile avec `cmp rsp,[r14+0x10]` (R14 pointe sur la goroutine courante, +0x10 est `stackguard0`). Je le neutralise en faisant pointer R14 sur un faux `g` dont `stackguard0=0`.

Exécuter les vrais `crypto/aes.NewCipher`/`crypto/cipher.NewCTR` sans le runtime Go complet n'est pas gratuit : aucune fonction `init()` n'est exécutée, l'émulation saute directement dans `encryptMethod`. Il faut neutraliser cinq routines du runtime, que l'émulation rencontre l'une après l'autre :

| Routine | Rôle réel | Neutralisation |
|---|---|---|
| `runtime.stdcall` | Appel DLL Windows (détection matérielle AES-NI, très probablement), via une variable `asmstdcallAddr` jamais remplie faute d'avoir exécuté `runtime.osinit()` | Forcée à retourner 0 ("fonctionnalité absente") : pousse `crypto/aes` vers son chemin logiciel pur, déterministe |
| `runtime.newobject` | Allocation typée sur le tas Go (jamais initialisé, faute de `runtime.schedinit`) | Redirigée vers un allocateur "bump" Python trivial |
| `runtime.makeslice` | Allocation de slice | Idem |
| `runtime.mallocgc` | Allocateur générique interne | Idem |
| `runtime.growslice` | Croissance de slice (`append`) | Idem, avec copie des données existantes préservée |
| `runtime.persistentalloc1` | Allocation hors-tas (réservation mémoire OS réelle, `VirtualAlloc`), cause du dernier `throw("runtime: cannot allocate memory")` rencontré | Idem |

Pour chacune, j'ai laissé l'émulation planter, puis j'ai demandé à l'API Ghidra quelle fonction contenait l'adresse fautive (nom, symboles référencés), au lieu de deviner depuis le désassemblage brut.

**Résultat**, avec un salt fixe (clé et IV arbitraires) et deux contenus différents :

```
salt (cle+IV, 48 o) : 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f404142434445464748494a4b4c4d4e4f
plaintext_a : b'CHUNK-A: this is the first 1MB-sized chunk of the SAME victim file.....'
plaintext_b : b'CHUNK-B: totally different content, second chunk of the SAME victim fil'

ciphertext_a (via le VRAI main.encryptMethod du binaire) : e0368a71dc77fbd5b30f0aaefe7bd82...
ciphertext_b (via le VRAI main.encryptMethod du binaire) : e0368a71dc77f8d5b30f0db3ec37dd2e...

ciphertext_a XOR ciphertext_b : 00000000000003000000071d124c050a0010010346031b171d0000...
plaintext_a  XOR plaintext_b  : 00000000000003000000071d124c050a0010010346031b171d0000...

CONFIRME (execution reelle du binaire) : ciphertext_a XOR ciphertext_b == plaintext_a XOR plaintext_b.
```

Les deux XOR sont identiques, et ils sont calculés par le vrai code machine du binaire, pas par une réimplémentation Python de l'AES. Script : `tools/emulate_encryptmethod_reuse_poc.py`.

## 5. Persistance sur une seconde lignée de code indépendante, et sur le build le plus récent

La lignée « ImmoRan » (`4cda6af6...`, `c2859adc...`, `d5f9595a...`, 2025-06-30, puis `2c3f8889...`, `c5f526cc...`, 2026-01-31) refait entièrement le module de chiffrement. `main.getEncryptionType` choisit un type de chiffrement selon l'extension et la taille du fichier : 0 = exclu ; 1 = fichier entier découpé séquentiellement en chunks de 1 Mio ; 2 et 3 = chiffrement intermittent par offsets, avec un pas de 1 Mio calculé pour couvrir le fichier à intervalles réguliers au lieu de tout chiffrer, sur des seuils de taille d'environ 100 Mio et 107 Gio. `main.startEncrypting` orchestre le tout :

```c
main_generateSalt(mVar9.~r0,mVar9.~r1.tab,mVar9.~r1.data);   // UNE SEULE FOIS
local_38 = extraout_RAX;                                      // le salt, conserve pour tout le fichier
...
if (iStack0000000000000008 == 1) {                             // type 1 : sequentiel
  while (0 < iVar6) {
    main_EncryptFile(pcStack..., iStack..., ..., iVar5, 0x100000, local_38, ...);  // meme salt
    iVar5 = iVar5 + 0x100000;
    iVar6 = local_50 + -1;
  }
}
else if (iStack0000000000000008 == 2) {                        // type 2 : intermittent, gros fichier
  main_EncryptFile(..., 0, 0x40000000, local_38, ...);          // meme salt
  ...
  while (0 < iVar5) {
    main_EncryptFile(..., iVar6, 0x100000, local_38, ...);      // meme salt, offset different
    iVar5 = local_58 + -1;
  }
}
```

Quel que soit le type (1, 2 ou 3), dès qu'un fichier demande plus d'un appel à `main.EncryptFile`, tous ces appels partagent le même `local_38`, le salt renvoyé par l'unique appel à `main_generateSalt` en tête de fonction. C'est exactement le défaut du §3, dans une architecture de chiffrement pourtant très différente (offsets calculés au lieu d'une boucle séquentielle). Deux équipes ou deux builds indépendants, et la même erreur.

J'ai vérifié ce schéma par décompilation complète sur `4cda6af6...`, `c2859adc...` et `2c3f8889...` (le plus récent, 2026-01-31). Sur `d5f9595a...` et `c5f526cc...`, la confirmation est structurelle : `main.startEncrypting` est présente et sa taille est identique à l'octet près à celle d'un des trois échantillons précédents. **Le build le plus récent connu (`2c3f8889...`, 2026-01-31, sept mois après les premiers échantillons) reproduit le défaut à l'identique. Il n'a pas été corrigé.**

## 6. Preuve de concept et récupération pratique

`tools/nightspire_keystream_reuse_poc.py` rejoue le scénario du §4 avec une bibliothèque AES standard (`cryptography`, déjà validée au bit près contre le vrai binaire au §4) au lieu de Ghidra/Unicorn, pour avoir une démonstration indépendante et rapide. Un chunk 0 supposé entièrement à zéro (une plage de remplissage, plausible sur les formats visés) sert à récupérer le keystream, qu'on applique ensuite à un chunk contenant un secret quelconque, sans jamais utiliser la clé AES ni la clé privée RSA :

```
Secret reellement present dans le chunk N (jamais donne a l'etape de recuperation) :
  b'CONTRAT CONFIDENTIEL - montant : 42 000 000 EUR - a ne divulguer sous aucun pretexte'

Recupere par XOR(chunk_N_chiffre, keystream_du_chunk_0) :
  b'CONTRAT CONFIDENTIEL - montant : 42 000 000 EUR - a ne divulguer sous aucun pretexte'

Correspondance exacte : True
```

**Outil pratique** : `tools/nightspire_recover_chunk.py` applique le même principe à un vrai fichier sur disque, chiffré par chunks de 1 Mio, et plus à une démonstration en mémoire :

```
nightspire_recover_chunk.py FICHIER.enc --known-offset 0 --known-zero 200
nightspire_recover_chunk.py FICHIER.enc --known-offset N --known-plaintext-file CLAIR_CONNU.bin --known-chunk I
```

Je l'ai testé sur un fichier synthétique de 4 chunks (1 rempli de zéros, 2 aléatoires, 1 contenant un secret) chiffré avec le mécanisme validé au §4. Le secret du 4e chunk est récupéré en entier à partir d'une seule hypothèse, « le premier chunk est du remplissage à zéro ». Les deux chunks aléatoires ne produisent aucun faux positif, comme prévu.

Ce qu'on récupère en pratique dépend de la prévisibilité du clair dans chaque chunk. Or les formats visés en priorité par le chemin vulnérable (images disque de VM, ISO, sauvegardes) contiennent typiquement de larges zones de remplissage ou des en-têtes standard. L'hypothèse de départ est donc réaliste sur une bonne partie des fichiers réels de ce type.

## 7. Portée et limites

- **La faille ne concerne que les fichiers chiffrés en plusieurs appels** (`EncryptFilev2` sur la lignée « Encrypter v2.1 », `startEncrypting` avec les types 1/2/3 sur la lignée « ImmoRan »), donc ceux qui dépassent le seuil de découpage en plusieurs chunks. Un fichier qui tient dans un seul chunk n'a qu'un appel de chiffrement et n'est pas touché.
- **La réutilisation se fait à l'intérieur d'un fichier, pas entre fichiers.** Chaque fichier reçoit un salt frais (tiré de `crypto/rand.Reader`, voir §2), donc deux fichiers d'une même victime ne partagent pas de keystream. D'autres familles déjà documentées ont le même type de défaut à l'échelle de toute une exécution, ce n'est pas le cas ici.
- **Je n'avais aucun fichier réellement chiffré par NightSpire.** La validation de bout en bout (§6) porte sur un fichier synthétique construit avec le mécanisme validé au bit près au §4, pas sur un chiffré produit par une vraie exécution du ransomware.
- **`d5f9595a...` et `c5f526cc...` n'ont pas été décompilés en détail** (voir §5). Leur appartenance à la lignée « ImmoRan » et la présence du défaut reposent sur une correspondance structurelle (nom et taille de fonction identiques à l'octet près à un échantillon décompilé), pas sur une relecture complète de leur pseudocode.
- **Je n'ai pas extrait la liste exacte des extensions visées par le chemin vulnérable sur la lignée « ImmoRan ».** `main.getEncryptionType` consulte trois listes de chaînes statiques dont je n'ai pas dumpé le contenu. La logique de dispatch par taille de fichier (§5) est confirmée, pas la liste des extensions associées à chaque type.
- **L'émulation ne porte que sur une fonction** (`main.encryptMethod`), pas sur toute la chaîne de chiffrement d'un fichier (lecture disque, boucle de chunks, écriture). Ça suffit pour valider la réutilisation de keystream, qui est l'objet de cette analyse, mais ce n'est pas une émulation complète du ransomware.

## 8. Indicateurs de compromission

| SHA256 | Lignée | Notes |
|---|---|---|
| `e275b8a02bf23b565bdaabadb220b39409eddc6b8253eb04e0f092d697e3b53d` | Encrypter v2.1 | Vulnérable, confirmé par émulation bit-exacte |
| `32e10dc9fe935d7c835530be214142041b6aa25ee32c62648dea124401137ea5` | Encrypter v2.1 | Vulnérable |
| `4cda6af63873eca99b9a0b69be1c8b9c81b501339f569a30fd30394fa54da3e1` | ImmoRan | Vulnérable |
| `c2859adc342cbcc36485ef6c3020913c459079acbeae3456b44297d7704f6853` | ImmoRan | Vulnérable |
| `d5f9595abb54947a6b0f8a55428ca95e6402d2aeb72cbc109beca457555a99a6` | ImmoRan | Vulnérable (tag MalwareBazaar absent, signature `None`) |
| `2c3f8889c9f5d765632ad91b4cc2a852c54845e143dbd2b68359b6f9cdfd8f99` | ImmoRan | Vulnérable, build le plus récent connu (2026-01-31) |
| `c5f526cc62688cf34c49d098dab81e24e4294f832ada57433ef505d5ac6da8f3` | ImmoRan | Vulnérable |

Contact de rançon observé dans les chaînes du binaire : `nightspireteam.receiver@onionmail.org`.

## 9. Divulgation et usage

Les échantillons étudiés sont publics (MalwareBazaar) et le ransomware est actif. Rien ici ne révèle une vulnérabilité dans un logiciel légitime. Je documente une faiblesse cryptographique du ransomware, qui peut servir à une victime pour évaluer une récupération partielle de ses données sans payer, ou à un chercheur ou un CERT pour construire un outil de récupération plus complet. Le mécanisme est confirmé en exécutant le code machine du binaire (§4) et vérifié sur tous les échantillons publiquement disponibles au moment de l'analyse (§5).

## 10. Reproduire l'analyse

Le journal détaillé est dans [`runbook.md`](writeups/nightspire-ransomware/runbook.md). Le PoC et l'outil de récupération de chunk sont dans [`tools/`](tools.html?slug=nightspire-ransomware) (`nightspire_keystream_reuse_poc.py`, `nightspire_recover_chunk.py`, `emulate_encryptmethod_reuse_poc.py`). La règle YARA : [`nightspire_keystream_reuse.yar`](writeups/nightspire-ransomware/nightspire_keystream_reuse.yar). Le guide de détection et de remédiation pour une victime : [`remediation.md`](writeups/nightspire-ransomware/remediation.md).
