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

Catégorie : Ransomware

Analyste : Gordon PEIRS (@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. Les scripts sont dans tools/.

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 :

__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 :

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 :

_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]) :

__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) :

_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 :

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. Le PoC et l'outil de récupération de chunk sont dans tools/ (nightspire_keystream_reuse_poc.py, nightspire_recover_chunk.py, emulate_encryptmethod_reuse_poc.py). La règle YARA : nightspire_keystream_reuse.yar. Le guide de détection et de remédiation pour une victime : remediation.md.