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.