Runbook : RagnarLocker (build non protégé, réutilisation de flux Salsa20)

Corpus#

SHA256 Première soumission Taille Format
9706a97ffa43a0258571def8912dc2b8bf1ee207676052ad1b9c16ca9953fc2c 2020-08-03 38 400 octets PE32 i386, non packé
dd79b2abc21e766fe3076038482ded43e5069a1af9e0ad29e06dce387bfae900 2020-10-01 55 808 octets PE32 i386, non packé

Outils : PyGhidra (analyse headless), pefile, scripts maison dans tools/ (find_api_callers.py, find_func_callers.py, decompile_addr.py, ragnarlocker_keystream_reuse_poc.py, ragnarlocker_recover_file.py). Aucune exécution réelle de l'échantillon.

Acquisition (MalwareBazaar)#

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=RagnarLocker&limit=1000" -o /tmp/rl_tag.json

25 échantillons taggés RagnarLocker. Triés par taille : deux entrées à 5-6 Mo (protégées VMProtect, hors sujet ici) contre une dizaine entre 38 Ko et 120 Ko (2020-07/08, tag RagnarLocker seul ou avec un nom de victime). Choisi le plus petit et le plus ancien.

h=9706a97ffa43a0258571def8912dc2b8bf1ee207676052ad1b9c16ca9953fc2c
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
  -d "query=get_file&sha256_hash=$h" -o "binaries/$h.zip"
cd binaries && 7z x -pinfected -y "$h.zip" && cd ..

Vérification directe (ne pas se fier au tag seul)#

import pefile
pe = pefile.PE("binaries/9706a97f....exe", fast_load=True)
pe.parse_data_directories()
# entropie max 6.52 sur toutes les sections (contre 7.95+ pour un build VMProtect)
# table d'import complete et normale : 53 KERNEL32, 20 ADVAPI32, 4 CRYPT32, etc.

Confirme un binaire natif standard, aucune trace de virtualisation/packer. Table d'import ADVAPI32/CRYPT32 correspond exactement au schéma documenté publiquement pour RagnarLocker : CryptAcquireContextW, CryptGenRandom, CryptEncrypt (génération de clé de session) + CryptDecodeObjectEx, CryptImportPublicKeyInfo (import de la clé publique RSA embarquée).

Localisation de la routine de chiffrement (Ghidra headless)#

python3 tools/find_api_callers.py binaries/9706a97f....exe CryptEncrypt out.txt
python3 tools/find_api_callers.py binaries/9706a97f....exe CryptGenRandom out.txt

CryptEncrypt n'est appelé qu'à un seul endroit (FUN_004018c0), avec un buffer de sortie taille_entree * 10 et Final=TRUE systématique : sert à chiffrer via RSA de petits blobs (une clé de session), pas le contenu des fichiers (Salsa20 n'est pas un algorithme CryptoAPI natif, donc le chiffrement de masse est forcément une implémentation maison ailleurs dans le binaire).

CryptGenRandom est appelé dans FUN_004031d0, qui elle-même est appelée deux fois dans entry() :

FUN_004031d0(&DAT_0040a208, 0x28);  // 40 octets aleatoires -> derives
FUN_004031d0(&DAT_0040a230, 0x20);  // 32 octets aleatoires -> derives

FUN_004031d0 ne renvoie pas les octets bruts de CryptGenRandom : ils passent par une construction hash dont l'état initial correspond exactement aux 8 mots IV de BLAKE2b (0x6a09e667f3bcc908, etc., vérifié octet par octet). Construction saine en elle-même (hash d'un aléa CSPRNG légitime), pas la source du problème.

Décompilation ciblée des fonctions internes (decompile_addr.py, variante de find_api_callers.py qui prend une adresse plutôt qu'un nom de symbole importé) :

python3 tools/decompile_addr.py binaries/9706a97f....exe out.txt \
    00407240 004072f0 00406f30 00407430   # construction BLAKE2b
python3 tools/find_func_callers.py binaries/9706a97f....exe 004031d0 out.txt
    # -> decompile entry(), revele l'appel double a FUN_004031d0
python3 tools/decompile_addr.py binaries/9706a97f....exe out.txt \
    004026b0 00402310 004022b0   # coeur Salsa20 + init d'etat

Constat central#

FUN_004026b0 (appelée une fois par fichier, au tout début de FUN_00401490, la routine de chiffrement par fichier) initialise l'état Salsa20 à partir des DEUX MÊMES globales (DAT_0040a208, DAT_0040a230) générées une seule fois par entry(), jamais régénérées entre deux fichiers. Le nonce (positions 6/7 de l'état) provient des 8 derniers octets de la globale de 40 octets ; le compteur de bloc (positions 8/9) est remis à zéro à chaque appel. Voir ragnarlocker-keystream-reuse.md pour l'analyse complète et la preuve de concept.

Vérification croisée du premier pas de quart-de-tour décompilé contre la formule Salsa20 standard (b ^= rotl(a+d, 7)) : correspondance exacte, opérandes et rotation. Confirme que la structure extraite de la décompilation est fiable, pas une lecture erronée.

Recherche d'une source publique contredisant ce constat#

Recherche web : Kaspersky/Securelist affirme que RagnarLocker génère une clé ET un nonce Salsa20 uniques par fichier, contredisant directement le constat ci-dessus. Plutôt que de laisser la contradiction non résolue, recherche d'un second échantillon plus tardif pour vérifier par décompilation directe si le défaut a été corrigé entre-temps.

# dans la liste RagnarLocker deja recuperee : candidats > 2020-08
# 2022-06-07 f4d742d8... (58880 octets) : ECARTE apres verification directe
#   (section .teslaX RWX simultane + table d'import illisible = packe,
#   pas comparable sans travail de deballage)
# 2020-10-01 dd79b2ab... (55808 octets) : retenu, meme profil d'imports
#   ADVAPI32/CRYPT32 que l'echantillon initial, entropie normale, non packe

h=dd79b2abc21e766fe3076038482ded43e5069a1af9e0ad29e06dce387bfae900
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
  -d "query=get_file&sha256_hash=$h" -o "binaries/$h.zip"
cd binaries && 7z x -pinfected -y "$h.zip" && cd ..

Comparaison directe (Ghidra headless)#

python3 tools/find_api_callers.py binaries/dd79b2ab....exe CryptGenRandom out.txt

CryptGenRandom est toujours dans une fonction équivalente (FUN_00404e90, même structure CryptAcquireContextW + CryptGenRandom + mélange 64 octets, mais le mélange utilise maintenant un second appel CryptGenRandom frais au lieu d'une constante fixe). Différence structurelle immédiatement visible : cette fonction a deux appelants distincts (FUN_00401a40, FUN_00402040), chacun l'appelant 4 fois, pas juste entry() deux fois.

python3 tools/find_func_callers.py binaries/dd79b2ab....exe 00404e90 out.txt

Décompilation complète des deux appelants : ce sont les routines de traitement par lots de 64 fichiers (avec un thread par fichier, ajouté entre les deux versions). FUN_00404e90 y est appelée à l'intérieur de la boucle sur les fichiers du lot, une fois par fichier :

puVar14 = (uint *)(contexte_fichier + 0xcf24);
FUN_00404e90(puVar14, 0x28);   // secret de 40 octets FRAIS par fichier
puVar10 = (uint *)(contexte_fichier + 0xcf4c);
FUN_00404e90(puVar10, 0x20);   // secret de 32 octets FRAIS par fichier

Confirme directement, par lecture de code sur un second échantillon réel, que le défaut du build du 2020-08-03 est corrigé sur celui du 2020-10-01 : chaque fichier reçoit ses propres secrets. Voir ragnarlocker-keystream-reuse.md §5 pour l'analyse complète.