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.