RagnarLocker : un seul flux Salsa20 pour tous les fichiers

Catégorie : Ransomware

Analyste : Gordon PEIRS (@ozer0x777)

Famille : RagnarLocker, ransomware actif depuis 2020.

Méthode : reverse statique (Ghidra headless, décompilation x86) de deux échantillons non protégés de 2020, plus une preuve de concept en Python pur qui réimplémente l'algorithme observé. Les échantillons ne sont jamais exécutés.


Résumé

Sur le plus ancien build daté connu de RagnarLocker (9706a97f..., première soumission publique le 2020-08-03), la clé de session Salsa20 (32 octets) et un second secret (40 octets) sont générés une seule fois, au tout début de l'exécution, avec CryptGenRandom puis une dérivation par hachage (BLAKE2b). La fonction qui initialise l'état Salsa20 de chaque fichier (placement des mots de clé et du nonce, remise à zéro du compteur de bloc) reçoit ces deux mêmes secrets globaux à chaque appel. Rien ne les fait varier, rien n'y ajoute un élément propre au fichier. La conséquence, vérifiée par le calcul (§4) : tous les fichiers chiffrés pendant une même exécution de ce build partagent exactement le même flux Salsa20, octet pour octet, dès le premier bloc de 64 octets.

Prenons quelqu'un qui possède deux fichiers chiffrés lors de la même infection, un attaquant ou une victime qui veut récupérer ses données. S'il connaît ou devine le début en clair de l'un des deux (un en-tête de format suffit : PK\x03\x04 pour un .docx/.xlsx/.zip, %PDF- pour un .pdf, etc.), un simple XOR entre les deux chiffrés lui donne le même segment en clair de l'autre fichier, sans la clé privée RSA de l'attaquant. C'est le défaut classique de réutilisation de flux, le « two-time pad », et il est démontré par le calcul au §4, sans exécuter le binaire.

La fenêtre de vulnérabilité est étroite, comme le montre un second échantillon. Un rapport public (Kaspersky/Securelist) dit que RagnarLocker génère une clé et un nonce Salsa20 propres à chaque fichier, ce qui contredirait ce défaut. J'ai donc regardé un second échantillon, daté du 2020-10-01 (dd79b2ab..., environ deux mois plus tard). Cette version génère bien un secret frais par fichier : le rapport public est exact pour ce build, mais pas pour celui du 2020-08-03. Le défaut semble donc avoir été corrigé par le développeur de RagnarLocker entre ces deux dates, soit une fenêtre d'environ deux mois. Voir §5.

Tout est reproductible depuis runbook.md. Les scripts sont dans tools/.

1. Échantillons

SHA256 Première soumission Taille Format Vulnérable
9706a97ffa43a0258571def8912dc2b8bf1ee207676052ad1b9c16ca9953fc2c 2020-08-03 38 400 octets PE32 i386, non packé Oui, voir §3-4
dd79b2abc21e766fe3076038482ded43e5069a1af9e0ad29e06dce387bfae900 2020-10-01 55 808 octets PE32 i386, non packé Non, voir §5

Les échantillons viennent de MalwareBazaar (tag RagnarLocker). Je les ai choisis parmi une dizaine de taille comparable (38-120 Ko), datés de juillet à octobre 2020, à ne pas confondre avec les builds protégés par VMProtect (5-6 Mo), tagués à part CAPCOM/CAMPARIGROUP. J'ai écarté un troisième candidat plus tardif (2022-06-07, f4d742d8...) après vérification : sa section .teslaX est à la fois en lecture, écriture et exécution, et sa table d'import est illisible. C'est un binaire packé, qu'on ne peut pas comparer aux deux autres sans un travail de déballage que je n'ai pas entrepris.

Je ne me suis pas fié au tag : j'ai vérifié les deux échantillons retenus. Entropie normale sur toutes les sections (maximum 6,52 et 6,48, contre 7,95+ pour un build virtualisé), table d'import complète et lisible, aucune trace de packing ni de virtualisation.

La table d'import colle exactement au schéma cryptographique documenté pour cette famille : CryptAcquireContextW/CryptGenRandom/CryptEncrypt (génération et enveloppement d'une clé de session) et CryptDecodeObjectEx/CryptImportPublicKeyInfo (import de la clé publique RSA embarquée).

2. Méthodologie

CryptEncrypt n'a qu'un seul site d'appel dans le binaire (FUN_004018c0), avec un buffer de sortie surdimensionné (taille_entrée × 10) et Final=TRUE à chaque fois. Cette fonction enveloppe donc un petit blob, une clé de session, en RSA. Elle ne fait pas le chiffrement de masse des fichiers. Comme Salsa20 n'existe pas dans la CryptoAPI de Windows, le chiffrement des fichiers est forcément une implémentation maison, ailleurs dans le binaire. Je l'ai trouvée en remontant les appelants de CryptGenRandom.

CryptGenRandom est appelé à un seul endroit, dans une fonction utilitaire (FUN_004031d0) elle-même appelée deux fois au tout début de entry() :

FUN_004031d0(&DAT_0040a208, 0x28);  // 40 octets aléatoires, dérivés
FUN_004031d0(&DAT_0040a230, 0x20);  // 32 octets aléatoires, dérivés

FUN_004031d0 ne renvoie pas l'aléa brut. Les octets de CryptGenRandom passent dans une construction de hachage dont l'état initial reprend, mot pour mot, les constantes IV standard de BLAKE2b (0x6a09e667f3bcc908, 0xbb67ae8584caa73b, etc., que j'ai vérifiées). C'est une construction saine : hacher un aléa de CSPRNG avec une fonction de hachage reconnue n'affaiblit rien. Le problème n'est pas là.

Ces deux secrets de 40 et 32 octets sont ensuite chiffrés séparément en RSA (FUN_004018c0, vue plus haut) et écrits en base64 dans une note de rançon (RGNR_<nom_machine>.txt). FUN_00401950 dépose cette note à la racine de chaque répertoire parcouru, puis appelle FUN_00401490 pour chaque fichier éligible qu'il contient.

3. Le défaut : initialisation d'état identique à chaque fichier

FUN_00401490, qui chiffre un fichier, commence par regarder si les 9 derniers octets valent déjà _RAGNAR_\0, le marqueur qui évite de rechiffrer un fichier par accident. Si le fichier n'est pas marqué, elle appelle :

FUN_004026b0(&local_a0, (undefined4 *)&DAT_0040a208, (undefined4 *)&DAT_0040a230);

local_a0 est un état Salsa20 de 16 mots de 32 bits (64 octets), que FUN_004026b0 remplit ainsi :

this[0]  = param_2[0];        // "constante" 0 : secret de 32 octets, mot 0
this[1..4] = param_1[0..3];   // clé : secret de 40 octets, mots 0-3
this[5]  = param_2[1];        // "constante" 1 : secret de 32 octets, mot 1
this[6..7] = 0, 0;            // nonce, temporairement a zero
this[8..9] = 0, 0;            // compteur de bloc, remis a zero
this[10] = param_2[2];        // "constante" 2
this[11..14] = param_1[4..7]; // clé : secret de 40 octets, mots 4-7
this[15] = param_2[3];        // "constante" 3

Juste après cet appel, toujours dans FUN_00401490, les positions 6-7 (le nonce) sont écrasées par les 8 derniers octets du secret de 40 octets (param_1[8]/param_1[9], que FUN_004026b0 n'utilise pas). On retrouve exactement la disposition standard de l'état Salsa20 (4 mots « constante », 8 mots de clé, 2 mots de nonce, 2 mots de compteur), avec :

  • la clé, tirée des 32 premiers octets du secret de 40 octets,
  • le nonce, tiré des 8 derniers octets de ce même secret de 40 octets,
  • le compteur de bloc, initialisé à zéro.

Rien dans cette initialisation ne dépend du fichier traité : ni son nom, ni son chemin, ni un compteur global incrémenté d'un fichier à l'autre, ni un second appel à CryptGenRandom. Et comme DAT_0040a208/DAT_0040a230 sont des globales écrites une seule fois par entry(), chaque fichier traité pendant la même exécution reçoit exactement le même état Salsa20 de départ, donc le même keystream dès le premier octet.

Pour m'assurer que je lis bien la décompilation, j'ai contrôlé le premier pas du premier quart-de-tour de FUN_00402310 (le cœur Salsa20, avec sa boucle de 10 doubles-tours). Il se lit local_18 = rotl(local_8 + local_94[0xc], 7) ^ local_18, où local_8/local_18/local_94[0xc] sont les mots d'état 0, 4 et 12. C'est exactement la formule Salsa20 standard x[4] ^= rotl(x[0] + x[12], 7).

4. Preuve de concept

tools/ragnarlocker_keystream_reuse_poc.py réimplémente l'algorithme en Python pur, sans exécuter le binaire : la même fonction d'initialisation d'état et le même cœur Salsa20 (vérifié pas à pas contre la formule standard, voir §3), appliqués à deux « fichiers » distincts chiffrés avec les mêmes secrets de 40/32 octets, comme le ferait une vraie exécution sur deux fichiers d'un même répertoire.

Résultat :

Keystream fichier A == Keystream fichier B : True
Prefixe recupere du fichier B (sans jamais connaitre la cle RSA) :
  b'PK\x03\x04\x14\x00\x00\x00\x08\x00'
Correspond au vrai debut de B : True

Le script chiffre deux textes clairs différents avec cette construction. Ensuite, en ne connaissant que les deux chiffrés et un en-tête PK\x03\x04... deviné pour le premier fichier (une signature de format, pas un secret), il retrouve le début en clair du second par un simple XOR entre les deux chiffrés. Cette étape n'utilise ni clé, ni nonce, ni aucune valeur secrète de l'algorithme. Elle exploite uniquement le fait que le flux est le même pour les deux fichiers.

Outil pratique : tools/ragnarlocker_recover_file.py applique le même principe à de vrais fichiers sur disque, et plus à une démonstration en mémoire :

ragnarlocker_recover_file.py FICHIER_CONNU.enc TEXTE_CLAIR_CONNU.bin FICHIER_CIBLE.enc

Ce qu'on récupère dépend de la quantité de texte clair connue pour le premier fichier, et on n'est pas limité aux quelques octets d'une signature. Exemple plus réaliste : un modèle de document entièrement connu (un modèle public téléchargeable, par exemple) sert de FICHIER_CONNU, face à un second fichier confidentiel au contenu complètement différent :

Vrai contenu secret du fichier cible (jamais donné à l'outil) :
  CONTRAT DE FUSION-ACQUISITION CONFIDENTIEL - montant : 42 000 000 EUR

Récupéré par l'outil, à partir du seul modèle public :
  CONTRAT DE FUSION-ACQUISITION CONFIDENTIEL - montant : 42 000 000 EUR

Correspondance exacte. Plus le clair connu du premier fichier est long, plus on récupère du second. Dans un cas réel, une victime qui possède un seul document non chiffré proche de ses documents chiffrés (même modèle, même logiciel, sauvegarde partielle retrouvée ailleurs) peut donc récupérer bien plus qu'un en-tête de format.

5. Comparaison avec le build du 2020-10-01 : fenêtre de vulnérabilité refermée

Une source technique publique (Kaspersky/Securelist, « Targeted ransomware: it's not just about encrypting your data! ») écrit : « The key and nonce values are also uniquely generated for each file ». Ça contredit ce que je décris aux §3-4 sur l'échantillon du 2020-08-03. Plutôt que de supposer qui a raison, j'ai décompilé de la même façon l'échantillon dd79b2ab... (2020-10-01, environ deux mois plus tard) pour comparer.

Sur ce second échantillon, l'équivalent de la fonction de génération de secret (FUN_00404e90, même construction CryptGenRandom + BLAKE2b qu'au §2) n'est plus appelé deux fois au début du programme. Il est appelé une fois par fichier, dans la boucle qui traite les fichiers par lots de 64. Entre les deux versions, le programme a aussi gagné un traitement parallèle, avec un thread par fichier :

// pour chaque fichier du lot (jusqu'a 64), execute par un thread dedie :
puVar14 = (uint *)(contexte_fichier + 0xcf24);
FUN_00404e90(puVar14, 0x28);   // secret de 40 octets FRAIS, propre a ce fichier
puVar10 = (uint *)(contexte_fichier + 0xcf4c);
FUN_00404e90(puVar10, 0x20);   // secret de 32 octets FRAIS, propre a ce fichier

Chaque fichier reçoit donc ses propres secrets, tirés indépendamment par CryptGenRandom, avant d'être chiffré dans son thread. La réutilisation de flux du §3 ne s'applique plus. L'affirmation de Kaspersky est donc exacte pour ce build, et je le dis après avoir lu le code, pas en faisant confiance à la source.

Conclusion : le défaut existe sur le plus ancien build connu de RagnarLocker (première soumission publique le 2020-08-03) et il est corrigé au plus tard sur le build connu suivant (2020-10-01), soit une fenêtre d'environ deux mois. D'autres builds intermédiaires existent peut-être entre ces deux dates, je n'ai pas cherché à tous les recenser.

6. Portée et limites

Cette réutilisation de flux (build du 2020-08-03 uniquement, voir §5) ne vaut que pour les fichiers chiffrés pendant une même exécution. Sur ce build, un secret de 40/32 octets frais est généré à chaque lancement du ransomware, donc pour chaque victime ou machine. C'est quand même le scénario le plus utile en pratique : une victime qui veut récupérer ses données a en général beaucoup de fichiers issus de la même infection, souvent du même type (documents bureautiques, images, bases de données), avec des en-têtes de format prévisibles ou connus.

Je n'ai pas mesuré précisément ce qu'on peut récupérer par fichier. L'algorithme chiffre par blocs dont la taille dépend de celle du fichier, avec un traitement partiel des gros fichiers que je n'ai pas entièrement quantifié. Il faudrait creuser ce point pour estimer la proportion récupérable au-delà du ou des premiers blocs.

7. Indicateurs de compromission

SHA256 Notes
9706a97ffa43a0258571def8912dc2b8bf1ee207676052ad1b9c16ca9953fc2c RagnarLocker, non protégé, 2020-08-03, vulnérable (§3-4)
dd79b2abc21e766fe3076038482ded43e5069a1af9e0ad29e06dce387bfae900 RagnarLocker, non protégé, 2020-10-01, secret régénéré par fichier, non vulnérable

Marqueur de fichier déjà chiffré (les deux builds) : les 8 derniers octets avant l'octet nul terminal valent _RAGNAR_. Note de rançon déposée sous le nom RGNR_<nom_machine>.txt.

8. Reproduire l'analyse

Le journal détaillé (commande, raison, sortie brute) est dans runbook.md. La preuve de concept et l'outil de récupération de fichier sont dans tools/ (ragnarlocker_keystream_reuse_poc.py, ragnarlocker_recover_file.py).