Runbook : reproduction pas à pas (stager BlackLotus, mars 2023)

A. Recherche de l'échantillon (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=BlackLotus&limit=1000" | python3 -c "
import json,sys
d=json.load(sys.stdin)
for e in d.get('data', []):
    print(e.get('first_seen'), e.get('sha256_hash'), e.get('file_size'), e.get('signature'))
"

Un seul échantillon disponible sous ce tag : d68f668b4240f9518e4f80499d93d8c5a1eddece0771658c33ae916cc54f5a66 (2023-03-11, 504 832 octets). Recherche parallèle du tag CosmicStrand : zéro résultat, piste abandonnée faute de source récupérable.

h=d68f668b4240f9518e4f80499d93d8c5a1eddece0771658c33ae916cc54f5a66
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 ..

B. Triage initial#

import pefile
pe = pefile.PE(f"binaries/{h}.exe", 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), hex(s.SizeOfRawData))
try:
    for e in pe.DIRECTORY_ENTRY_IMPORT:
        print(e.dll.decode())
except AttributeError:
    print("aucune table d'import")

Résultat : PE32+ x86-64, 5 sections, aucune table d'import, section .data à entropie 8.0/8.0 sur 461 Ko (sur 504 Ko de fichier total). Signale une résolution d'API dynamique et un payload chiffré/compressé embarqué, plutôt qu'un simple packer générique.

C. Décompilation Ghidra headless (PyGhidra), vue d'ensemble#

source ~/.venvs/ctf-reverse/bin/activate
python3 tools/entry_and_overview.py binaries/$h.exe tools/overview.txt

tools/entry_and_overview.py (nouveau, pour cette analyse) liste le point d'entrée, les fonctions triées par taille et les blocs mémoire. Résultat : 123 fonctions seulement, point d'entrée entry à 140001320, .data (14000c000, RW non-X) confirmé comme la zone du payload embarqué.

python3 tools/decompile_addr.py binaries/$h.exe tools/decomp_entry_and_top.txt \
  0x140001320 0x140002e50 0x140004a20 0x140002240 0x1400073a8 0x140005cb0

entry() enchaîne une trentaine d'appels d'initialisation puis FUN_140002240() (résolution d'API) et FUN_140002e50() (le corps principal : extraction et installation du payload).

D. Cassage de l'obfuscation de chaînes#

FUN_1400057d4 (chaînes wide/UTF-16) et FUN_14000564c (chaînes ASCII) décodent, à l'appel, une table statique encodée en .rdata : lecture à l'envers, XOR conditionnel (+0xa0 si octet/unité ≥ 0x80, sinon inchangé), XOR chaîné avec l'unité déjà décodée, permutation par paires adjacentes, puis miroir complet.

python3 tools/decompile_addr.py binaries/$h.exe tools/decomp_strhelpers.txt \
  0x14000564c 0x1400057d4 0x140002888 0x1400028f4 0x140002b98

Réimplémentation Python de l'algorithme (tools/decode_strings.py), testée sur les deux variants : attention à la condition du variant ASCII (>= 0x80, pas < 0x80, qui sort du texte non imprimable). Le variant wide décode "AES", "ChainingModeCBC", "bcrypt.dll", etc. Résultat : décodage en clair de la clé AES utilisée plus loin :

python3 tools/decode_strings.py binaries/$h.exe a 0x140009f80 33
# -> '6jxkPUHKRK5rgdg0PkhLhuS844uMYgrm'

Décodage en lot de toutes les chaînes référencées par FUN_140002e50 (extraction des paires adresse/longueur directement depuis tools/decomp_entry_and_top.txt) : noms de DLL (user32.dll, advapi32.dll, Rpcrt4.dll, bcrypt.dll, ole32.dll, Cabinet.dll), chemins de fichiers EFI/registre, l'hôte et les chemins réseau (§G), et plusieurs chaînes système (SeBackupPrivilege, SeRestorePrivilege, \BaseNamedObjects\, etc.). Voir blacklotus-uefi-bootkit.md §3 pour la liste complète.

E. Résolution d'API par hash#

FUN_140002b98 : parcourt PEB->Ldr (liste des modules déjà chargés) par hash de nom, pour amorcer la résolution sans appeler LoadLibrary. FUN_140002888 : LoadLibrary classique sur un nom de DLL décodé (une fois la chaîne en clair obtenue via FUN_1400057d4). FUN_1400028f4 : parcourt la table d'export du module résolu, hash chaque nom exporté et compare à une constante ; gère aussi la redirection API Set (api-*/ext-*) par forwarder PE, donc pas une simple resolution-by-hash générique copiée-collée mais une implémentation qui respecte les redirections modernes de Windows 10+.

F. Déchiffrement des blobs embarqués (AES-256-CBC, clé statique)#

Dans FUN_140002e50, quatre appels à FUN_1400010e0/FUN_140006954 déchiffrent chacun un blob à une adresse .data fixe, tous avec la même chaîne clé décodée à l'étape D. Décompilation de FUN_1400010e0 : séquence BCryptOpenAlgorithmProvider("AES") -> BCryptSetProperty(ChainingModeCBC) -> BCryptGetProperty(ObjectLength) -> BCryptGenerateSymmetricKey(secret=32 octets) -> BCryptDecrypt(pbIV=NULL, dwFlags=BCRYPT_BLOCK_PADDING), confirmée en décodant les chaînes d'algorithme CNG passées en paramètre (§D).

python3 tools/decrypt_payload.py binaries/$h.exe decrypted/

Résultat (AES-256-CBC, IV nul, clé 6jxkPUHKRK5rgdg0PkhLhuS844uMYgrm, retrait manuel du padding PKCS7 équivalent à BCRYPT_BLOCK_PADDING) :

Blob (adresse .data) Chiffré Clair Identifié comme
0x1400734e0 9 632 o 9 624 o PE64 natif (driver noyau)
0x140075a80 12 304 o 12 288 o ruche de registre Windows
0x140078a90 16 400 o 16 384 o ruche de registre Windows
0x140021fc0 333 088 o 333 084 o encore compressé (§H)

Trois des quatre blobs sont directement exploitables après ce seul déchiffrement AES. Le quatrième (0x140021fc0, le plus gros) nécessite une étape supplémentaire, repérée dans le code : FUN_140006954 appelle FUN_140007198 juste après le déchiffrement AES.

G. Décodage du chemin réseau alternatif#

Décompilation de FUN_1400079ac (requête HTTPS) et des chaînes associées : connexion à msdl.microsoft.com sur le port 443, trois requêtes GET vers les chemins /download/symbols/bootmgfw.efi/7144BCD31C0000/bootmgfw.efi, /download/symbols/bootmgr.efi/98B063A61BC000/bootmgr.efi et /download/symbols/hvloader.efi/559F396411D000/hvloader.efi, soit le format d'URL standard du serveur de symboles public de Microsoft. Ce chemin de code récupère donc des copies saines et légitimes des fichiers de démarrage visés, directement depuis l'infrastructure Microsoft, plutôt qu'un quelconque C2 attaquant.

H. Décompression du blob principal (LZMS)#

FUN_140007198 résout et appelle trois fonctions de Cabinet.dll : CreateDecompressor(Algorithm=5, ...), Decompress(...), CloseDecompressor(...). Algorithme 5 = COMPRESS_ALGORITHM_LZMS (API Windows Compression). Le code appelle Decompress une première fois avec un buffer de sortie nul pour obtenir la taille requise, puis une seconde fois pour décompresser réellement : signature caractéristique du mode "buffer" (sans le flag COMPRESS_RAW), où le flux compressé encode lui-même sa structure interne (par opposition au mode "block"/COMPRESS_RAW, où l'appelant gère tout en externe).

Tentative avec wimlib (échec, format incompatible) : wimlib (paquet Arch officiel extra/wimlib, installé via pacman) implémente LZMS pour le format WIM et expose une fonction bas niveau wimlib_decompress() documentée comme équivalente au mode raw (COMPRESS_ALGORITHM_LZMS | COMPRESS_RAW) de l'API Windows, pas au mode "buffer" utilisé ici. Test de non-régression du binding ctypes (compression puis décompression d'un texte de test avec l'API wimlib elle-même) : succès, donc le binding est correct. Balayage de tailles candidates (tools/lzms_bruteforce_size.py) de 16 octets à 4 Mio sur le blob complet : zéro succès sur toute la plage, y compris pour des tailles minuscules, ce qui confirme un problème de format dès le premier octet, pas une mauvaise taille de sortie.

Reconstruction empirique du format "buffer mode" : aucune documentation publique trouvée sur le format exact de ce mode (je n'ai eu accès qu'aux extraits de résultats de recherche, pas aux pages elles-mêmes). Inspection directe des 24 premiers octets du blob déchiffré par AES (0x1400734e0 reste hors sujet ici) : un champ à l'offset 4 (u16) vaut 0x18 (= 24, taille d'en-tête plausible), un champ à l'offset 8 (u32) vaut 928592, un champ à l'offset 16 (u32) vaut 65536 (une puissance de deux, plausible comme taille de chunk), et un champ à l'offset 24 (u32) vaut 19284. Hypothèse testée directement : offset 24 = taille compressée du premier chunk, chunk lui-même décodable comme un bloc LZMS raw standard (donc via wimlib_decompress) produisant exactement 65536 octets de sortie.

python3 tools/lzms_container_decompress.py decrypted/blob_main_0x140021fc0.bin decrypted/blob_main_decompressed.bin

Premier essai : succès immédiat, sortie commençant par 4d 5a ("MZ"). Généralisation à la totalité du blob (boucle sur des chunks [u32 taille_compressee][donnees], 65 536 octets de sortie par chunk sauf le dernier) : 15 chunks, 928 592 octets décompressés au total, un PE32+ EFI application complet. Format documenté en tête de tools/lzms_container_decompress.py.

I. Identification du contenu final#

file decrypted/blob_0x1400734e0.bin decrypted/blob_main_decompressed.bin
sha256sum decrypted/*.bin
strings decrypted/blob_0x1400734e0.bin | grep -iE 'efi|boot|hive'
python3 -c "
import re
data = open('decrypted/blob_main_decompressed.bin','rb').read()
for m in re.finditer(rb'(?:[\x20-\x7e]\x00){5,}', data):
    print(m.group(0).decode('utf-16le'))
" | grep -iE 'shim|mok|grub|debug'

Le driver (0x1400734e0) référence en clair EFI\Microsoft\Boot\bootmgfw.efi, EFI\Microsoft\Boot\bootload.efi, MokList, system32\hvloader.efi, system32\BCD. Le blob principal décompressé est un exécutable EFI application (sous-système PE 10), identifié par sa chaîne de débogage add-symbol-file /usr/lib/debug/usr/share/shim/x64-15.4-5.fc33/shimx64.efi.debug comme un binaire du projet shim (rhboot/Fedora), avec ses chaînes natives de gestion du Machine Owner Key (MokList, MokListRT, import_one_mok_state, mirror_mok_db, install_shim_protocols, init_grub, \\grubx64.efi).

Environnement#

Identique aux autres analyses de ce dépôt : Ghidra headless via PyGhidra, pefile, venv ~/.venvs/ctf-reverse, clé API MalwareBazaar dans .mb_api_key à la racine. Spécifique à cette analyse : wimlib (paquet Arch extra/wimlib, fournit libwim.so.15, utilisé uniquement pour son décodeur LZMS bas niveau via ctypes, pas pour ses outils WIM de haut niveau) et pycryptodome (AES-256-CBC) dans le même venv.