Runbook : reproduction pas à pas (RagnarLocker/VMProtect, identification de cible)
A. Écarter la première piste (INC Ransom) : vérification directe contredit le rapport tiers#
Le sample cité par le rapport Acronis comme "protégé VMProtect 3.X" a été identifié par recoupement de marqueurs de chaînes (curve25519-dalek, SALSA20/EncryptionAlgo, BCryptGenRandom, OpenSCManagerW, GetDriveTypeW, GetSystemInfo) : SHA256 c3d8c86844d7bb86dea31bcb0510542fb59bc3bc67a4e3df59f72da5c8509f54. Vérification directe (pefile, entropie de section) : entropie normale sur toutes les sections, IAT complète et lisible, chaînes de debug Rust en clair. Contredit l'affirmation initiale du rapport, confirme sa propre section technique détaillée ("no code virtualization employed"). Piste abandonnée, aucun travail de dévirtualisation entamé dessus.
B. Recherche d'un vrai échantillon ransomware protégé par VMProtect (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=VMProtect&limit=1000" -o /tmp/vmprotect_tag.json
python3 -c "
import json
d = json.load(open('/tmp/vmprotect_tag.json'))
for e in d['data']:
print(e.get('first_seen'), e.get('sha256_hash')[:16], e.get('file_type'), e.get('file_size'), e.get('signature'), e.get('tags'))
"
58 échantillons taggés VMProtect sur MalwareBazaar. La plupart sont des RAT/stealers/loaders (FatalRAT, ValleyRAT, LummaStealer, Rhadamanthys, Cobalt Strike...), aucune famille de ransomware directement nommée dans ce jeu de résultats brut. Deux entrées se distinguent par leur taille inhabituelle (5-6 Mo contre 40-120 Ko pour un RagnarLocker typique) et leurs tags additionnels CAPCOM/CAMPARIGROUP : recoupement avec le tag RagnarLocker.
curl -s -X POST https://mb-api.abuse.ch/api/v1/ -H "Auth-Key: $KEY" \
-d "query=get_taginfo&tag=RagnarLocker&limit=100" -o /tmp/rl_tag.json
python3 -c "
import json
d = json.load(open('/tmp/rl_tag.json'))
for e in sorted(d['data'], key=lambda x: x.get('first_seen','')):
print(e.get('first_seen'), e.get('sha256_hash'), e.get('file_size'), e.get('tags'))
"
Confirme : 9416e5a57e6de00c685560fa9fee761126569d123f62060792bf2049ebba4151 (CAPCOM, 5 359 456 octets) et afab912c41c920c867f1b2ada34114b22dcc9c5f3666edbfc4e9936c29a17a68 (CAMPARIGROUP, 6 026 592 octets), tous deux tagués RagnarLocker + VMProtect + packed, première soumission 2020-11-26, contre 38-120 Ko pour les échantillons RagnarLocker "normaux" non protégés du même tag. L'écart de taille correspond exactement à ce qu'ajoute un enrobage VMProtect (section virtualisée de plusieurs Mo).
C. Acquisition et vérification directe (ne pas se fier au tag seul)#
h1=9416e5a57e6de00c685560fa9fee761126569d123f62060792bf2049ebba4151
h2=afab912c41c920c867f1b2ada34114b22dcc9c5f3666edbfc4e9936c29a17a68
for h in $h1 $h2; do
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 ..
done
import pefile
for f in [f"binaries/{h1}.exe", f"binaries/{h2}.exe"]:
pe = pefile.PE(f, fast_load=True)
pe.parse_data_directories()
for s in pe.sections:
print(s.Name.decode(errors="ignore").strip("\x00"), round(s.get_entropy(),2), s.SizeOfRawData, hex(s.VirtualAddress))
for e in pe.DIRECTORY_ENTRY_IMPORT:
print("DLL", e.dll.decode(), "n=", len(e.imports))
print("EntryPoint RVA", hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint))
Retour :
- CAPCOM : sections .ctr0 (vide, SizeOfRawData=0) / .ctr1 (5 338 112 octets, entropie 7,95).
- CAMPARIGROUP : sections .vmp0 (vide) / .vmp1 (6 003 200 octets, entropie 7,96), nommage VMProtect par défaut, non renommé.
- IAT fragmentée sur les deux (KERNEL32.dll apparaît 3 fois dans la table avec des tailles différentes), point d'entrée situé à l'intérieur de la section virtualisée sur les deux.
D. Octets d'entrée : motif d'entrée VMProtect confirmé#
import pefile
pe = pefile.PE(f"binaries/{h2}.exe")
ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint
print(pe.get_memory_mapped_image()[ep:ep+32].hex())
print(pe.FILE_HEADER.TimeDateStamp)
Retour : 68 06d2495d e8 32b1f6ff ... (push imm32 puis call rel32, motif d'entrée dispatcher VMProtect classique). TimeDateStamp = 1603207057 (2020-10-20 15:17:37 UTC), identique sur les deux fichiers : même build protégé, redéployé sur les deux victimes ~5 semaines plus tard.
E. Harnais de traçage dynamique Unicorn (tools/vmp_trace.py)#
python3 tools/vmp_trace.py binaries/afab912c41c920c867f1b2ada34114b22dcc9c5f3666edbfc4e9936c29a17a68.exe --budget 20000000 --dump-ring --ring-size 150
Construit un GDT minimal (FS -> faux TEB/PEB) car UC_X86_REG_FS_BASE est un no-op silencieux en mode 32 bits sur Unicorn 2.1.4 (piège à connaître, cf. avertissement de dépréciation à l'exécution). Patch chaque slot IAT avec une adresse synthétique hookée en exécution ; construit une fausse DLL en mémoire (en-tête PE + table d'export) à la volée pour chaque LoadLibraryA/GetModuleHandleA par nom, afin que le parcours manuel de table d'export (bootstrap classique de protecteur pour retrouver kernel32/ntdll sans IAT) résolve vers les mêmes stubs qu'un GetProcAddress normal.
Deux bugs de harnais trouvés et corrigés par l'échec observé (crash sur adresse aléatoire ou comportement incohérent) :
1. La valeur de retour par défaut de la table de stubs n'était pas câblée (toujours 0 au lieu du défaut déclaré), faisant échouer silencieusement GetProcessWindowStation() (retour NULL au lieu d'un handle factice), ce qui ressemble à un déclenchement de détection anti-sandbox.
2. WTSSendMessageW déclaré avec 9 arguments stdcall au lieu de 10 (vraie signature MSDN) : désalignement de pile silencieux qui provoque un crash sur une adresse aléatoire une fois les instructions suivantes exécutées.
Résultat après corrections : émulation stable sur ~4,8 millions d'instructions, jusqu'à WTSSendMessageW puis un ExitProcess(0xDEADC0DE) contrôlé.
Fragment de trace capturé (--dump-ring) juste avant l'appel à un stub : révèle le handler VM générique d'appel de fonction externe (boucle de dépilement d'un tableau d'arguments virtuel, empilage stdcall, puis call eax), un détail de fonctionnement de cette protection jamais documenté publiquement pour RagnarLocker.
F. De ExitProcess(0xDEADC0DE) au run complet « A à Z », ExitProcess(0) propre#
ExitProcess(0xDEADC0DE) sans aucun message affiché est la terminaison silencieuse standard de VMProtect (loader_status interne resté à LOADER_ERROR, FreeImage() → ExitProcess(0xDEADC0DE), jamais un LoaderMessage()), pas un blocage propre à RagnarLocker. Plusieurs bugs de harnais, franchis un par un, ont fait progresser l'exécution jusqu'au vrai point d'entrée applicatif :
peb->OSBuildNumberjamais résolu (aucune valeur ne correspond àIS_KNOWN_WINDOWS_BUILD()) →LoaderMessage(mtDebuggerFound)inconditionnel. Corrigé par--realistic-os-version(PEB+0xAC=19041,WINDOWS_10_20H1).- Deux instructions
SYSENTERnatives non interceptées (stub d'import factice\xC3bare-RET) plutôt qu'un vrai numéro de syscall (SSN) résolu et dispatché. HeapCreate,GetModuleHandleW/LoadLibraryW,TlsAlloc/TlsSetValue/TlsFree,EncodePointer/DecodePointer,Interlocked*,GetCurrentThreadId,HeapDestroy, absents ou mal typés dans les tables d'arité du harnais, chacun bloquant silencieusement la phase bootstrap CRT/VMProtect à un stade différent. Corrigés un par un, par ordre d'apparition du blocage suivant.
Commande finale (échantillon CAPCOM) :
python3 tools/vmp_trace.py binaries/9416e5a57e6de00c685560fa9fee761126569d123f62060792bf2049ebba4151.exe \
--wide-api-sweep --real-seh --realistic-os-version --budget 2000000000
Volontairement sans --survive-exitprocess : ce flag neutralise le premier ExitProcess rencontré pour "voir au-delà", utile pour explorer, mais il masque la vraie fin naturelle du programme dès qu'elle est atteinte. S'en passer laisse le harnais s'arrêter proprement dessus (confirmé identique, à l'instruction près, entre les deux modes une fois ce point atteint).
Résultat : arrêt contrôlé sur ExitProcess(0) : 1 751 245 135 instructions exécutées, 3075 appels d'API interceptés, aucun crash. Séquence complète en §3 du writeup.
Deux bugs de harnais supplémentaires, trouvés en creusant les deux threads secondaires (CreateThread, §4 du writeup) :
- lstrcatW/lstrcatA/lstrcpyW/lstrcpyA/wsprintfW/wsprintfA absentes des tables d'arité (comportement par défaut = aucune écriture mémoire réelle), tronquaient silencieusement lpApplicationName/lpCommandLine du second CreateProcessAsUserW (celui qui relance notepad.exe sur la note de rançon). Corrigées avec de vraies implémentations (concaténation/copie fidèle à l'encodage W/A, parsing minimal de format %s/%c/%d/%x/%X).
- Les threads imbriqués s'exécutaient de façon synchrone en consommant tout le budget restant : un watchdog de surveillance de process (boucle CreateToolhelp32Snapshot/Process32First/NextW, jamais vu avant ce correctif) épuisait ainsi la totalité du budget sans jamais rendre la main au thread principal. Corrigé par un plafond dédié (--nested-thread-cap, défaut 20 000 000 instructions), appliqué via un vrai fetch invalide plutôt qu'une exception Python : une exception levée dans un hook imbriqué fuyait vers l'appel emu_start() englobant à cause d'un attribut d'état partagé (_hook_exception) jamais réinitialisé dans les bindings Python d'Unicorn 2.1.4 (voir run_nested_thread() dans tools/vmp_trace.py).
Après ces deux correctifs, le run ci-dessus révèle son vrai contenu : la commande exacte du second CreateProcessAsUserW (notepad.exe + chemin de la note de rançon !!!_READ_ME_<ID hexadécimal 8 chiffres>_!!!.txt) ; détail complet en §3.7 du writeup.