Runbook : reproduction pas à pas (TAG-150 / NightshadeC2)

Recherche préalable et choix du point d'entrée#

DenoRAT, CastleBot et DinDoor n'ont pas de tag dédié sur MalwareBazaar (get_taginfo renvoie no_results pour les trois). Trois tags disponibles avec des échantillons réels : CastleRAT (44), CastleLoader (17), NightshadeC2 (9). DenoRAT chargeant directement NightshadeC2 d'après les sources publiques consultées (eSentire, Recorded Future), ce tag a été choisi comme point d'entrée le plus court vers la chaîne complète.

Acquisition#

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=NightshadeC2&limit=100" -o /tmp/nightshadec2_tag.json

9 résultats, deux dates groupées (2025-10-06/07/11) et deux plus tardives (2025-12-15). Les 3 premiers (2025-10-06) sont livrés en ZIP, les 6 suivants en PE direct.

for h in HASH1 HASH2 ...; 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}.enc"
done

Les 3 premiers sont des ZIP MalwareBazaar (mot de passe infected) contenant eux-mêmes un ZIP interne libre :

7z x -pinfected -olayer1 -y fichier.zip.enc
7z x -y -osample_extrait layer1/interieur.zip

Les 6 suivants sont des PE directs sous le même mot de passe MalwareBazaar (7z x -pinfected), pas de double couche.

Triage du kit de DLL sideloading#

ls sample1_consent/consent/
# c1.dll c1xx.dll c2.dll cnt.exe DASMHLP.HLP disco.exe wmsgapi.dll ...
file sample1_consent/consent/cnt.exe sample1_consent/consent/disco.exe

Identification formelle via les infos de version PE (pefile, pe.FileInfo) :

import pefile
pe = pefile.PE('cnt.exe')
for fi in pe.FileInfo:
    for entry in fi:
        if hasattr(entry, 'StringTable'):
            for st in entry.StringTable:
                for k, v in st.entries.items():
                    print(k.decode(), '=', v.decode())

cnt.exe -> OriginalFilename = consent.exe, CompanyName = Microsoft Corporation : vrai consent.exe UAC Windows 7 SP1. Sa table d'imports (pe.DIRECTORY_ENTRY_IMPORT) liste WMsgAPI.dll : sideloading confirmé.

Vérification que les autres fichiers du dossier (c1.dll/c1xx.dll/c2.dll/atlprov.dll/disco.exe) ne sont pas fonctionnellement liés à cnt.exe : comparaison croisée de leurs tables d'imports respectives (aucune référence entre eux, chacun importe uniquement des DLL système standard ou son propre runtime, ex. mspdb100.dll/MSVCP100.dll pour les DLL du compilateur).

wmsgapi.dll : taille annoncée ~650 Mo, PE réel tenant dans les deux premiers Mo (sections listées via pefile, PointerToRawData/SizeOfRawData), confirmé par le taux de compression ZIP observé.

Troncature à 4 Mo pour analyse Ghidra pratique (largement suffisant pour couvrir toutes les sections réelles) :

head -c 4194304 wmsgapi.dll > trimmed/wmsgapi_trimmed.dll
python tools/list_functions.py trimmed/wmsgapi_trimmed.dll tools/funcs_wmsgapi.txt
python tools/decompile_by_name.py trimmed/wmsgapi_trimmed.dll tools/decomp_wmsgapi_entry.txt __DllMainCRTStartup WmsgBroadcastMessage WmsgSendMessage

Les 6 exports Wmsg* font 1 octet chacun (Ghidra : Bad instruction, commentaire forwarder -> C:\Windows\System32\wmsgapi.XXX) : purs forwarders vers la vraie DLL système. __DllMainCRTStartup = CRT MSVC2010 standard, rien d'ajouté visible directement dedans.

Triage des 6 échantillons restants#

7z liste directement la structure PE de chaque .exe (sections/certificat/table COFF) sans extraction préalable :

7z l fichier.exe

050d061d... et df4425b4... révèlent une CERTIFICATE et une COFF_SYMBOLS ; les 4 autres n'ont ni l'une ni l'autre.

Identification des chaînes runtime Go (strings | grep "runtime: panic before malloc heap initialized") sur 050d061d... et df4425b4... : confirme des binaires Go 1.24-ish malgré des noms de fichiers/métadonnées ne laissant rien deviner. Les 4 autres échantillons n'ont aucune chaîne Go : natifs C/C++.

python tools/list_functions.py 050d061d....exe tools/funcs_050d061d.txt main.
# 1937/3932 fonctions matchees -- symptome d'une obfuscation massive du paquet main

Vérification des certificats Authenticode#

Extraction brute de la table de certificat (IMAGE_DIRECTORY_ENTRY_SECURITY, champ VirtualAddress qui est en réalité un offset fichier, pas une RVA) :

import pefile
pe = pefile.PE('050d061d....exe')
d = pe.OPTIONAL_HEADER.DATA_DIRECTORY[4]
raw = pe.write()[d.VirtualAddress+8 : d.VirtualAddress+d.Size]  # +8 saute l'en-tete WIN_CERTIFICATE
open('cert.p7', 'wb').write(raw)
openssl pkcs7 -inform DER -print_certs -in cert.p7 -text -noout | grep -E "Subject:|Not Before|Not After"

Révèle la chaîne complète jusqu'au certificat feuille (DeepL SE / Shenzhen Tuozhu selon l'échantillon).

Vérification cryptographique réelle avec signify (pas seulement une lecture de la chaîne, une validation) :

pip install signify
from signify.authenticode.signed_file import SignedPEFile
with open('050d061d....exe', 'rb') as f:
    pe = SignedPEFile(f)
    result, e = pe.explain_verify()
    print(result, e)
# AuthenticodeVerificationResult.INVALID_DIGEST

(Le chemin d'import correct est signify.authenticode.signed_file.SignedPEFile, pas signify.authenticode.SignedPEFile qui n'est pas ré-exporté au niveau du paquet.)

Décompilation de main.main et confirmation du chargeur PE manuel#

python tools/decompile_by_name.py 050d061d....exe tools/decomp_main_050d061d.txt main.main

Lecture de main.main : séquence LoadLibrary("ntdll.dll") + GetProcAddress("NtUnmapViewOfSection") + appel via syscall.Syscall, puis plusieurs appels à des fonctions renommées mots-salade. Décompilation ciblée de ces fonctions pour identifier leur vrai rôle :

python tools/decompile_by_name.py 050d061d....exe tools/decomp_helpers_050d061d.txt \
  main.Foliatureoosporic main.Consequentlyscorch main.Serridentinesmystificatory main.Embouchuretorse \
  main.Physonectaepraisefully main.Kaibarthaendosalpingitis main.Biblicismtonsillectome

Chacune identifiée par son comportement (pas son nom) : lecture de e_lfanew, appel VirtualAlloc via syscall.LazyDLL, memcpy, copie section par section façon debug/pe.Section.Data(), memset, résolution de table d'imports façon debug/pe.ImportDirectoryEntry (détail complet en tag150-denorat-killchain.md §5).

Extraction et décodage du payload embarqué#

Le buffer constant embarqué est repéré dans main.main via son type Go récupéré par Ghidra (_247434_uint8___Array_type, taille explicite dans le nom). L'adresse DAT_0058b1c2 référencée par le désassemblage s'est avérée être 10 octets après le vrai début du buffer (vérifié en comparant les octets lus à cette adresse aux octets que Ghidra affichait comme des affectations littérales juste avant dans le pseudocode : ils coïncidaient exactement, confirmant que Ghidra avait scindé à tort une seule copie contiguë).

Recherche du bon sens de transformation par tâtonnement (deux directions possibles pour un XOR-like ±0x46 par parité) : la première tentative n'a produit aucun texte lisible ; la tentative opposée a produit 16 octets consécutifs de lettres minuscules ASCII pures (ytilibatimiksife...), statistiquement très improbable par hasard, confirmant le bon sens. Ce texte s'est ensuite avéré être des noms de types RTTI C++ lisibles mais inversés caractère par caractère (.?AVtype_info@@ lu à l'envers) : indice qu'il fallait inverser le buffer entier, ce qui a produit un en-tête MZ valide.

python tools/decode_embedded_payload.py binaries/layer1/050d061d....exe 0x58b1c2 0x3c68a payload_extrait.exe
# 247434 octets ecrits, signature MZ presente : True
sha256sum payload_extrait.exe
# e32b50f1b9ab8ccda64e4046780e002b350ece9c93ff4fa245fe0526ada14a1a
python tools/find_api_callers.py payload_extrait.exe WinHttpConnect tools/c2_winhttpconnect.txt

Confirme l'appel WinHttpConnect avec un nom de serveur passé en paramètre (pas une chaîne statique) : configuration réseau injectée/déchiffrée à l'exécution, pas trouvée en clair par cette voie.

Même recette appliquée à df4425b4... (blob de taille voisine, 0x3c69f octets, repéré de la même façon) : essai systématique de décalages 0-19 et des deux parités, aucun n'a produit MZ. Structure confirmée identique (même approche de chargeur, même ordre de grandeur), payload non extrait.

Vérification finale (bourrage VS2010, exports des DLL restantes)#

import pefile
for f in ['cnt.exe','c1.dll','c1xx.dll','c2.dll','atlprov.dll','disco.exe']:
    pe = pefile.PE(f, fast_load=True)
    pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_IMPORT']])
    print(f, [e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT] if hasattr(pe,'DIRECTORY_ENTRY_IMPORT') else [])

Confirme l'absence de lien fonctionnel entre les fichiers VS2010 et cnt.exe.

for f in ['04a1852a....exe', '8940944e....exe', 'b79878b2....exe', 'e7d7d805....exe']:
    pe = pefile.PE(f, fast_load=True)
    pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_EXPORT']])
    print(f, [e.name.decode() if e.name else e.ordinal for e in pe.DIRECTORY_ENTRY_EXPORT.symbols] if hasattr(pe,'DIRECTORY_ENTRY_EXPORT') else [])

Confirme que b79878b2.../e7d7d805... sont bien de la même famille wmsgapi.dll (6 exports identiques), et que 04a1852a.../8940944e... sont des EXE natifs sans export (non décompilés en détail à ce stade).

Recherche du C2 réel dans le payload extrait#

Recherche d'empreintes crypto directes (AES S-box Rijndael, table Te0 optimisée, instructions AES-NI via capstone, constante d'initialisation SHA-256, constante ChaCha20 "expand 32-byte k") sur le payload extrait : aucune trouvée. Le déchiffrement AES-256-CBC documenté publiquement pour NightshadeC2 ne s'applique donc probablement pas à ce module précis (ou pas à la configuration réseau elle-même).

python tools/find_api_callers.py payload_extrait.exe WinHttpConnect tools/c2_winhttpconnect.txt

Révèle un premier appelant qui interroge www.ip-api.com (line/?fields=147457, port 80) : une vérification de géolocalisation/statut VPN de la victime, pas le C2 (chaînes "success", "(VPN)" lues directement via decompile_addr.py sur les adresses référencées).

Recherche large de chaînes ressemblant à un domaine ou une IP dans tout le binaire (pas seulement les appelants d'une API donnée) :

strings -e l -n 6 payload_extrait.exe | grep -aE "^[a-zA-Z0-9.-]+\.(com|net|org|xyz|top|ru|io)$"
strings -e l payload_extrait.exe | grep -aE "^([0-9]{1,3}\.){3}[0-9]{1,3}$"

Révèle une adresse IP en clair : 85.208.84.115. Localisation de son adresse fichier (grep -a -t x), conversion en RVA/VA (pe.get_rva_from_offset), puis recherche de la fonction référençant cette adresse :

python tools/identify_addr.py payload_extrait.exe 0x14003a0e0

Révèle FUN_1400010d0, la fonction d'initialisation principale du programme (appelée depuis __scrt_common_main_seh, donc le vrai point d'entrée). Décompilation complète :

python tools/decompile_addr.py payload_extrait.exe tools/c2_ip_context.txt 0x1400010d0

Révèle en une seule fonction : vérification d'instance unique, persistance par tâche planifiée JAVA_V3 (commande PowerShell entièrement en clair dans le binaire), lancement du thread keylogger, la vérification ip-api.com déjà repérée, collecte d'empreinte machine, et enfin l'appel vers la boucle principale de C2 avec l'adresse 85.208.84.115 copiée dans un buffer juste avant.

Décompilation de cette boucle principale (FUN_14000cb40) et de la fonction de connexion (FUN_140004720) qu'elle appelle :

python tools/decompile_addr.py payload_extrait.exe tools/c2_beacon_funcs.txt 0x14000cb40 0x140008cc0 0x1400297a0
python tools/decompile_addr.py payload_extrait.exe tools/c2_connect_func.txt 0x140004720

Confirme getaddrinfo/socket/connect par ordinal sur ws2_32.dll (pas WinHttpConnect, qui ne servait qu'à la géolocalisation), port lu via une chaîne séparée (identify_addr.py sur l'adresse référencée, puis lecture brute des octets pour obtenir "7777"), et une poignée de main par comparaison de chaîne magique ("RandOmKey322666B", 16 octets) avant tout échange de commandes. La boucle principale lit ensuite un en-tête de commande de 32 octets et distribue vers 13 gestionnaires selon un octet d'opcode (table complète en tag150-denorat-killchain.md §6bis, gestionnaires individuels non décompilés).

Extraction du second payload (df4425b4) et décompilation des gestionnaires de commandes#

Nouvelle tentative sur df4425b4... : plutôt que de re-tester une plage de décalages/parités à l'aveugle, relecture précise du désassemblage de main.main pour ce build précis (grep -n "local_30->F\|puVar10 = " tools/decomp_main_df4425b4.txt) : décalage réel de 15 octets (pas 10) entre l'adresse DAT_xxxxxxxx et le vrai début du buffer, constante de transformation 0x5e (pas 0x46, la constante Go source correspondante '^' au lieu de 'F'). Une fois ces deux paramètres corrigés, extraction réussie du premier coup (pas de recherche exhaustive nécessaire) :

python3 - <<'PY'
import pefile
pe = pefile.PE('df4425b4....exe', fast_load=True)
off = pe.get_offset_from_rva(0x61cc42 - pe.OPTIONAL_HEADER.ImageBase)
with open('df4425b4....exe','rb') as f:
    f.seek(off - 15); raw = f.read(0x3c69f)
dec = bytearray(len(raw))
for i,b in enumerate(raw):
    dec[i] = (b - 0x5e) & 0xff if i%2==0 else (b + 0x5e) & 0xff
open('payload2.exe','wb').write(bytes(dec)[::-1])
PY

Comparaison octet à octet avec le premier payload extrait (cmp, puis lecture des octets différents) : seulement 130 octets de différence, tous en toute fin de fichier, tous du texte de bourrage lisible (même thème « mots-salade » que le reste de la campagne). Vérification des chaînes caractéristiques (85.208.84.115, JAVA_V3, RandOmKey322666B) présentes à l'identique dans les deux payloads.

Décompilation des 13 gestionnaires de commandes en un seul appel groupé, adresses collectées depuis le switch de FUN_14000cb40 (tools/c2_beacon_funcs.txt) :

python tools/decompile_addr.py payload1.exe tools/c2_opcode_handlers.txt \
  0x14001d430 0x140002360 0x1400048f0 0x14000e920 0x14000ee40 0x14000e750 0x14000c1b0 \
  0x14000d6f0 0x14000c080 0x140006760 0x140006420 0x140008e50 0x140007e90 0x14000fb20 0x1400079b0

Lecture individuelle de chaque fonction (détail complet en tag150-denorat-killchain.md §6bis) : identification par les chaînes en clair qu'elles référencent (u_C__Windows_System32_cmd_exe, u__TEMP__Vidosik_mp4, u_keylog_txt, u_C__Windows_System32_rundll32_exe) et par les API Windows appelées (CreateProcessW+pipes pour les shells, GetDC/CreateCompatibleDC pour les captures d'écran, SetWindowsHookExW déjà repéré dans l'initialisation).

Triage rapide des deux derniers échantillons (04a1852a, 8940944e)#

strings -e l -n 8 04a1852a....exe | grep -iE "wmsgapi|consent|deepl|java_v3|randomkey|85\.208\.84\.115|vidosik|keylog"
# aucun resultat sur les deux fichiers
python3 -c "
import pefile
pe = pefile.PE('04a1852a....exe')
print(hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint))
for s in pe.sections: print(s.Name, hex(s.Misc_VirtualSize))
print([e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT])
"

.text volumineux (1,2-1,8 Mo, pas un petit chargeur avec blob embarqué comme les loaders Go), pas d'import réseau (WS2_32/WINHTTP absents), aucune chaîne caractéristique du reste de la chaîne : rôle exact non déterminé, décompilation complète non menée.

Rédaction#

tag150-denorat-killchain.md, README.md, remediation.md et une règle YARA rédigés à partir des constats ci-dessus, mis à jour après la découverte du C2 réel puis après l'extraction du second payload et la décompilation des gestionnaires de commandes.

Clôture des 3 derniers points ouverts#

Trace DenoRAT#

Recherche web plutôt que MalwareBazaar directement (tags DenoRAT/DinDoor inexistants sur MalwareBazaar, vérifié par get_taginfo). Clarification de la relation exacte entre CastleLoader/CastleBot/CastleRAT/PyNightshade/NightshadeC2/DinDoor/DenoRAT (plusieurs noms provenant d'éditeurs différents pour des composants parfois identiques, parfois distincts) : NightshadeC2 (eSentire) = CastleRAT variante C (Insikt Group), PyNightshade = CastleRAT variante Python. DenoRAT est un composant réellement distinct, basé sur le runtime Deno/JavaScript.

Rapport eSentire (« DinDoor, DenoRAT, and NightshadeC2: Analyzing TAG-150's Evolving Tradecraft ») confirmant la chaîne séquentielle complète avec 5 hashs cités (Griffin20.ps1, DinDoor, DenoRAT stager, DenoRAT, payload NightshadeC2). Vérification systématique sur MalwareBazaar :

API_KEY=$(cat ../.mb_api_key)
for h in 4ac3e1ecd2c4745f2e846ec7655b92234ee92068bffc7690c3f0184dc25c71da \
         ece42baaca7524460cc5ffc9ae94c2a634a67610decdd2bdc8f134c36fa2dd6c \
         7682e9ac86ff47cc198812719ca54e169c3cb21ce584ad2a278a640f4833de46 \
         3dffe05d9cc49b2598d743301a8991965056d97bb8d429de461ed66c0dbde28b \
         c1e0a0543b12c64726f8bb36389010d7f35e57993a22ce5b73c56b9cc2a87a00; do
  curl -s https://mb-api.abuse.ch/api/v1/ -d "query=get_info&hash=$h" -H "Auth-Key: $API_KEY" | python3 -c "import json,sys; print(json.load(sys.stdin).get('query_status'))"
done

Les 5 : hash_not_found. Piste close avec réponse documentaire, pas d'échantillon obtenu.

Origine du certificat donneur#

Extraction directe de la table de certificat Authenticode et de l'empreinte SHA1 du certificat feuille DeepL SE :

python3 -c "
import pefile
p = pefile.PE('050d061d....exe', fast_load=True)
p.parse_data_directories()
sec = p.OPTIONAL_HEADER.DATA_DIRECTORY[4]
data = p.write()[sec.VirtualAddress:sec.VirtualAddress+sec.Size]
open('/tmp/deepl_cert.p7', 'wb').write(data[8:])
"
openssl pkcs7 -inform DER -print_certs -in /tmp/deepl_cert.p7 -out /tmp/deepl_certs.pem
csplit -s -z -f /tmp/deepl_cert_ -b "%02d.pem" /tmp/deepl_certs.pem '/-----BEGIN CERTIFICATE-----/' '{*}'
openssl x509 -in /tmp/deepl_cert_03.pem -noout -subject -fingerprint -sha1
# sha1 Fingerprint=34:BF:69:1C:9E:1D:00:18:F5:C5:9D:A0:26:61:04:0C:1E:AD:6B:E4

Recherche de cette empreinte et du numéro de série sur VirusTotal (recherche web + tentative GUI directe), Hybrid Analysis (un résultat trouvé pour un fichier DeepL.exe/ZeroInstall.Stub.exe mais sans signature valide, donc pas le donneur), et bazaar.abuse.ch/browse/signature/DeepL%20SE/ (aucun résultat). Vérification indépendante que les données d'identité du certificat sont réelles (recherche du numéro HRB 104617 sur les registres du commerce allemand publics) : confirmé authentique, DeepL SE existe bien à cette adresse à Cologne. Conclusion : copie d'un fichier réel non identifié, pas une fabrication.

Triage détaillé de 04a1852a et 8940944e#

python3 -c "
import pefile, datetime
for f in ['04a1852a....exe', '8940944e....exe']:
    p = pefile.PE(f)
    print(f, datetime.datetime.fromtimestamp(p.FILE_HEADER.TimeDateStamp, datetime.timezone.utc))
    for e in p.DIRECTORY_ENTRY_IMPORT: print(' ', e.dll.decode())
"

Compilés 2023-08-19 et 2024-07-02 (avant l'activité connue de TAG-150). Imports centrés sur gdiplus.dll/GDI32.dll/ole32.dll/USER32.dll, aucun réseau. Métadonnées de version extraites (pefile + FileInfo) : mêmes suites de mots anglais/pseudo-scientifiques sans rapport que les loaders Go (Gravidae venenately shame, Tacos fustiest niches...), confirmant l'appartenance au même outillage de génération automatique.

Recherche de chaînes ciblées :

strings -a 04a1852a....exe | grep -iE "CreateDXGIFactory|DuplicateOutput|D3D11CreateDevice"

CreateDXGIFactory présent dans les deux fichiers (bootstrap de la Desktop Duplication API DirectX, capture d'écran matérielle), absent de la table d'imports statique (résolution dynamique LoadLibrary("dxgi.dll") + GetProcAddress, cohérent avec le reste de la chaîne qui évite les imports statiques directs). Combiné à gdiplus.dll (encodage d'image) et ole32.dll (initialisation COM requise par DXGI), conclusion : modules de capture d'écran dédiés, probablement invoqués par le processus principal NightshadeC2 pour les opcodes de diffusion d'écran déjà décompilés (§6bis du writeup). Pas d'exports (DIRECTORY_ENTRY_EXPORT absent) : exécutables autonomes, pas des DLL à charger, CreateMutex+GetCommandLine présents dans les deux (cohérent avec un processus enfant à instance unique recevant des arguments).

Décompilation complète de la logique de réception d'ordre (comment le processus principal déclenche la capture) non menée, jugée hors périmètre.

Mise à jour finale#

Dernier point ouvert : déclenchement réel de wmsgapi.dll#

Triage initial des imports (pefile) sur binaries/trimmed/wmsgapi_74d2910c.dll (4 Mio, déjà tronquée d'une précédente session) : table d'imports anormalement riche pour un simple relais de forwarders (USER32.dll/GDI32.dll/gdiplus.dll avec des dizaines de fonctions de manipulation de fenêtres/GDI+), et section .data à entropie 7,94 (quasi aléatoire, 131 584 octets).

Localisation de __DllMainCRTStartup (Ghidra headless) : décompilation confirme un appel à FUN_180020a00(param_1, param_2, param_3), le vrai callback DllMain utilisateur, jamais suivi lors du triage initial.

Tentative de décompilation complète de FUN_180020a00 : échec (Low-level Error: Flow exceeded maximum allowable instructions), la fonction couvre une plage d'adresses de 1,7 Mo. Pivot vers radare2 pour l'info de fonction :

r2 -q -e "bin.cache=true" wmsgapi_74d2910c.dll <<'EOF'
aaa
afi @ 0x180020a00
EOF

size: 1718758 mais realsz: 65594 (octets d'instructions réels), num-bbs: 4, edges: 3 : signe d'une fonction quasi linéaire malgré sa taille apparente.

Désassemblage complet en texte brut :

r2 -q -e "bin.cache=true" -e "scr.color=0" wmsgapi_74d2910c.dll <<'EOF' > /tmp/wmsgapi_dllmain_full.asm
aaa
pd 8000 @ 0x180020a00
EOF

Repérage du dispatch initial : cmp dword [rsp + 0x2ac58], 1 (test de fdwReason == DLL_PROCESS_ATTACH) suivi d'un je vers le bloc principal ou d'un jmp de sortie directe. Le corps du bloc principal (5044 occurrences de mov [rsp+X], ...) écrit des valeurs éparpillées sur une plage de pile de ~175 Ko (0x930 à 0x2ac58), sans structure contiguë identifiable.

Script Python de reconstruction (/tmp/reconstruct_buffer.py, non conservé dans tools/, résultat négatif) : parcours du désassemblage en ordre d'adresse, suivi de eax/rax au fil des mov eax, imm/movabs rax, imm, application de chaque écriture byte/word/dword/qword à un buffer simulé. Résultat sur les dix plages les plus denses : aucune ne dépasse 8% de remplissage, aucun contenu ASCII lisible, aucune signature de PE (MZ à l'offset 0 absent, chaîne DOS stub absente). Conclusion : pas un payload chiffré reconstruit par petites écritures (contrairement à l'hypothèse de départ), plutôt du bruit délibérément inséré pour gonfler taille et complexité apparente.

Confirmation définitive par recherche exhaustive des références sortantes de la fonction entière (pas seulement l'échantillon désassemblé) :

r2 -q -e "bin.cache=true" wmsgapi_74d2910c.dll <<'EOF'
aaa
axff @ 0x180020a00
EOF

Résultat : exactement 3 références au total dans toute la fonction (1,7 Mo d'adresses) : le dispatch initial (2 sauts internes) et un seul appel, vers la routine de sondage de pile standard __chkstk (fcn.1801c4400, confirmée par décompilation comme le stack probe MSVC classique). Aucun autre appel, aucune autre branche, nulle part dans la fonction. Conclusion définitive : wmsgapi.dll n'exécute aucune action malveillante au-delà du sideloading et du relais forwarder ; le déclenchement réel de la charge n'a pas été localisé parce qu'il n'existe pas dans ce build.

tag150-denorat-killchain.md §2, §7 et le résumé mis à jour ; remediation.md §4 mis à jour (l'ancienne formulation "déclenchement non localisé" était devenue trompeuse, remplacée par la conclusion négative).

Clarification des opcodes 0x59 et 0xcd#

Décompilation ciblée des trois gestionnaires concernés, adresses déjà connues depuis le premier passage (tag150-denorat-killchain.md §6bis) :

python tools/decompile_addr.py binaries/trimmed/nightshadec2_payload_from_050d061d.exe /tmp/opcode_59_c9_cd.txt \
  0x14000c1b0 0x140007e90 0x1400079b0

0x59 (FUN_14000c1b0) : lecture attentive de la construction du buffer local_1d8 juste avant l'envoi final (Ordinal_19(local_1b0, local_1d8, 10, 0)) : local_1d8[0]=5, [1]=0, [2]=0, [3]=1 correspond exactement à l'en-tête d'une réponse SOCKS5 réussie (VER/REP/RSV/ATYP). C'est bien un relais SOCKS5, pas un simple pivot générique supposé.

0xcd (FUN_1400079b0) vs 0xc9 (FUN_140007e90) : la seule différence de code entre les deux est l'appel à FUN_140015d60(NULL, 0, FUN_1400074a0, lVar4, 0, local_48) dans 0xcd, absent de 0xc9. Décompilation de FUN_140015d60 : wrapper CRT autour de CreateThread (signature à 6 paramètres correspondant exactement). Décompilation de FUN_1400074a0 (la routine de thread) :

python tools/decompile_addr.py binaries/trimmed/nightshadec2_payload_from_050d061d.exe /tmp/opcode_cd_thread.txt \
  0x1400074a0 0x140015d60

Boucle de réception de structures INPUT Win32 (sizeof(INPUT)=0x28), injectées via SendInput : coordonnées souris normalisées par GetSystemMetrics, clics, clavier, et un type de message personnalisé (0x97) qui pousse une chaîne reçue dans le presse-papiers (OpenClipboard/SetClipboardData) puis simule Ctrl+V (séquence SendInput avec VK 0x11 et 0x56). Confirme 0xcd = 0xc9 + canal de contrôle interactif complet.

tag150-denorat-killchain.md §6bis (deux lignes de tableau réécrites) et §7 (point ouvert supprimé) mis à jour ; remediation.md §3 (liste des capacités) complétée avec le relais SOCKS5.

Modules DXGI : bootstrap réel confirmé, canal de sortie introuvable#

Triage initial de 04a1852a....exe (pefile) : point d'entrée réel à 0x1401b2b2c. Localisation de __tmainCRTStartup et de son appel vers la fonction "main" utilisateur, FUN_1400035d0.

Tentative de décompilation complète : échec Ghidra (Flow exceeded maximum allowable instructions). Info de fonction radare2 : désaccord avec Ghidra sur les bornes (r2 : 64 Ko de corps réel, zéro branchement ; Ghidra : 1,3 Mo). Vérification directe (au lieu de trancher entre les deux outils) : énumération de toutes les instructions CALL/JMP réelles via l'API Ghidra listing.getInstructions() sur les bornes de Ghidra :

instrs = listing.getInstructions(f.getBody(), True)
# comptage : 4640 CALL, 202 JMP, 241 sauts conditionnels, 227798 autres

Confirme un vrai contrôle de flot (pas juste du bourrage comme wmsgapi.dll), cohérent avec un control-flow flattening réel plutôt qu'un simple padding.

Localisation du bootstrap DXGI#

Repérage d'une petite table d'adresses fixes (0x1401bb0xx-0x1401bb3xx) appelées indirectement (CALL qword ptr [0x1401bbXXX]) partout dans la fonction : probable table de pointeurs résolus dynamiquement (LoadLibraryA/GetProcAddress, tous deux importés statiquement), cohérent avec l'absence de dxgi.dll dans la table d'imports statique alors que la chaîne CreateDXGIFactory est présente dans le binaire.

Confirmation du slot 0x1401bb118 = GetProcAddress par lecture du contexte autour de plusieurs sites d'appel (argument RDX chargé juste avant par un LEA RDX,[adresse], dont le contenu est un nom de fonction lisible une fois résolu via pefile.get_data(), contournement du même bug de lecture mémoire Ghidra déjà rencontré sur Devman et Lynx).

Recherche du site de résolution de CreateDXGIFactory spécifiquement (pas les résolutions génériques du CRT hors de la fonction principale) :

# parcours en un seul passage de listing.getInstructions(f.getBody(), True)
# suivi du dernier "LEA RDX,[0x...]" vu, declenchement sur CALL vers le slot GetProcAddress

Un seul résultat dans le corps de la fonction principale, à 0x140147271, résolvant l'adresse 0x14022b900 = "CreateDXGIFactory\0\0\0\0\0\0\0dxgi.dll" (vérifié par lecture directe via pefile, la chaîne suit immédiatement en mémoire). Désassemblage du contexte immédiat : le pointeur résolu est stocké puis appelé quelques instructions plus loin (CALL qword ptr [RSP+0x19970]), avec vérification du HRESULT de retour (CMP ...,0 ; JNZ <sortie>), suivi d'une séquence d'appels de méthodes COM par vtable ([RAX+0x10]/[RAX+0x40]/[RAX+0x48]) cohérente avec EnumAdapters/EnumOutputs/QueryInterface/DuplicateOutput.

Recherche du déclenchement et du canal de sortie (négative, exhaustive)#

Fenêtre de 600 instructions avant et 3000 après le bootstrap : aucun WaitForSingleObject, ReadFile, ou fonction de réception. Recherche des callers de WriteFile/CreateFileW/GetStdHandle dans tout le binaire : WriteFile n'a que 7 sites d'appel, tous dans le runtime C interne (0x1401b3xxx-0x1401b8xxx), aucun depuis FUN_1400035d0.

Recherche de chaînes pour des mécanismes alternatifs :

strings -a 04a1852a....exe | grep -iE "^ws2_32|^wsasend|^wsarecv|^send$|^recv$|^connect$|createnamedpipe|\\\\pipe\\\\"
strings -a 04a1852a....exe | grep -iE "^CreateFileMapping|^MapViewOfFile|^NtWriteFile|^DuplicateHandle"
strings -a 04a1852a....exe | grep -E "^Gdip[A-Z]"

Aucun résultat sur les trois recherches : ni réseau (statique ou résolu dynamiquement comme CreateDXGIFactory), ni mémoire partagée, ni sauvegarde d'image via l'API C de GDI+. Conclusion : recherche exhaustive de toutes les pistes plausibles, aucun canal de sortie identifié dans le code examiné.

tag150-denorat-killchain.md : nouvelle section §6ter, résumé, §7 et table IOC mis à jour. remediation.md §4 mis à jour.