AgentTesla : un leurre naval, 1 891 cases de flattening et une exfil FTP

Catégorie : Infostealer

Analyste : Gordon PEIRS

Famille : AgentTesla (infostealer/keylogger, MaaS)

Type : analyse statique jusqu'au §10 (je n'ai jamais exécuté le binaire). La seule étape qui repose sur une exécution faite par un tiers est expliquée dans la note de méthode du §10.


Résumé

AgentTesla se cache ici derrière CTM.exe, un faux outil de dimensionnement de structures navales récupéré sur MalwareBazaar. La chaîne de chargement passe par une ressource .NET chiffrée (octets inversés, puis AES-128 avec la clé réutilisée comme IV) et aboutit à un stage 2 protégé par Eziriz .NET Reactor. Je l'identifie par une chaîne de licence trial restée en clair, et ANY.RUN le confirme de son côté. Le vrai obstacle est un control-flow flattening massif : 1 891 cases de dispatcher, que j'ai désobfusquées avec deux outils écrits pour l'occasion (deflatten_cs.py, linearize_cs.py). Le chemin d'injection réel est tracé à 97 % (OpenProcess/VirtualAlloc/WriteProcessMemory/VirtualProtect) jusqu'à un payload final chiffré. Trois sandboxes indépendantes confirment qu'il s'agit d'AgentTesla (process hollowing classique par SetThreadContext) et qu'il exfiltre les identifiants Outlook volés vers un serveur FTP (ftp.piovau.com).


Ce document rassemble tout le récit de l'enquête (constats, hypothèses, conclusions) pour ses quatre volets. La preuve de travail reproductible (commande, raison, sortie brute) est à part, dans runbook.md, organisé selon les mêmes quatre parties. Les outils autonomes sont dans tools/.


1. Contexte et choix de l'échantillon

C'est la suite directe de l'enquête StealC (voir ../stealc-autoit-killchain/). Après une première famille en AutoIt et en natif, j'ai volontairement choisi un vecteur différent, un loader .NET, pour élargir ma boîte à outils (décompilation .NET, protecteurs commerciaux .NET) plutôt que de refaire un cas similaire.

L'échantillon vient de MalwareBazaar. J'ai cherché des samples .NET/CIL récents tagués AgentTesla, en ne gardant que les .exe réellement compilés en CIL (et pas les scripts .js/.vbs de première étape qui dominent le flux du jour). La recherche se fait par signature AgentTesla, avec un filtrage côté client sur les vrais binaires .NET/MSIL/CIL.

2. Identité de l'échantillon (stage 1)

Champ Valeur
SHA256 bc6d86cef1b7404823c1d830387b2c9b1289c453620482fc1749dd5d2ade3897
Nom de fichier CTM.exe
Taille 1 320 960 octets
Type PE32, .NET/CIL (Mono/.NET), 3 sections
Première observation (MalwareBazaar) 2026-07-14 08:53:50
Tag communautaire AgentTesla
Reporter threatcat_ch

Je prends le tag communautaire comme point de départ, pas comme confirmation, avec la même réserve que pour StealC. Elle est levée au §10.

3. Outillage

  • pefile, pour le parsing PE et l'extraction de ressources natives
  • capa 9.4.0 + règles officielles, pour la détection de capacités
  • ilspycmd 10.1.1 (dotnet tool install -g ilspycmd), décompilateur .NET en ligne de commande, cross-platform
  • .NET SDK 10.0 (pacman -S dotnet-sdk), nécessaire pour ilspycmd et les outils construits ensuite
  • pycryptodome, pour le déchiffrement AES scripté

Tout le travail est fait en local, sans exécuter le binaire. Contrairement à StealC (du Windows natif, inexécutable sous Linux sans Wine), un binaire .NET pourrait en théorie tourner avec dotnet ou Wine. Je garde la même discipline : jamais d'exécution, et un chmod -x dès le téléchargement.


4. Stage 1 : analyse statique du leurre et chaîne de chargement AES

4.1 Capa

12 capacités matchées, les plus significatives :

  • compiled to the .NET platform
  • access .NET resource
  • decrypt data using AES via .NET
  • invoke .NET assembly method
  • reference anti-VM strings targeting Xen
  • reference analysis tools strings

C'est le profil d'un loader .NET qui déchiffre en AES une ressource embarquée, puis charge un second assembly par réflexion. La lecture du code, dans la suite de cette section, le confirme.

4.2 Décompilation (ilspycmd)

ilspycmd -o decompiled/ <sha256>.exe

→ 16 811 lignes de C# décompilé, 64 classes. Noms d'identifiants aléatoires (0fdPNa5xi7Z, 2Cnrxq, aKg4d0BnFz3...) : obfuscation d'identifiants systématique.

Ce style (chaînes alphanumériques courtes et aléatoires) évoque ConfuserEx, et d'autres échantillons AgentTesla sur MalwareBazaar portent le tag communautaire ConfuserEX. Je n'ai pas vérifié ce rapprochement sur ce sample : le protecteur du stage 1 n'est pas identifié (voir §12). Celui du stage 2 l'est, au §5, et c'est un autre produit (Eziriz .NET Reactor).

4.3 Point d'entrée

[STAThread]
public static void 4Rzepd2M9()
{
    DateTime dateTime = DateAndTime.DateSerial(25, 6, 2026);
    ...
    if (!Xf8i0tdBN4k())

Le vrai point d'entrée n'a pas de nom parlant, il est renommé comme le reste. Je l'ai repéré grâce à l'attribut [STAThread], le seul indice fiable qui ne dépend pas du renommage.

Vérifié : Xf8i0tdBN4k() fait return true; sans condition. C'est une porte factice, ou le reste d'un contrôle retiré par le protecteur, pas un vrai kill switch. La variable dateTime (25/06/2026, soit une semaine avant le first_seen de MalwareBazaar) est construite, mais je n'ai pas tracé ce qu'elle devient. Je préfère la laisser telle quelle plutôt que de surinterpréter.

4.4 Le leurre visible : une fausse application métier complète

En cherchant le contexte du point d'entrée, l'application affiche (via des handlers d'événements MessageBox) :

  • "Stiffener optimization algorithm executed. Profiles selected to minimize weight while satisfying CSR rules."
  • "Profile filter dialog would open here to filter by type, weight, or dimensions."
  • "Custom profile import wizard would open here to read from CSV or XML."
  • "Plate report exported to CSV." / "Stiffener report exported to CSV."

L'application dessine aussi sur un Bitmap des libellés techniques : "STRENGTH DECK", "INNER BOTTOM", "HOPPER TANK", "BOTTOM PLATE". C'est du vocabulaire de structure navale. CSR (Common Structural Rules) est un vrai référentiel de classification maritime, pas un terme inventé.

Conclusion : CTM.exe n'est pas un leurre minimal. C'est une fausse application complète et cohérente de dimensionnement de structures de navire (calcul de raidisseurs, filtrage de profils, export de rapports), avec une interface qui a l'air de fonctionner. Ça colle avec un ciblage du secteur maritime, probablement livré par phishing ciblé. Je n'ai pas pu confirmer le vecteur de livraison (voir §12).

4.5 Chaîne de chargement vers le stage 2

Traçage de la logique de chargement en mémoire :

  1. Une ressource manifest .NET, Genitalk.klaoxao.tiff (459 024 octets), est extraite via Assembly.GetExecutingAssembly().GetManifestResourceStream(...).
  2. Les octets sont inversés (Array.Reverse) : évasion simple de scanners de signature statique.
  3. Déchiffrement AES-128-CBC, avec une clé codée en dur réutilisée comme IV (e4a931cb6e204322da0d1a30d946633b) : faille d'implémentation du malware, pas de ma méthode.
  4. Chargement du résultat par réflexion : Assembly.Load(byte[]) n'est jamais appelé littéralement : le code recherche par réflexion une MethodInfo ayant la forme de cet appel puis l'invoque (methodInfo.Invoke(null, new object[] { bytes })), pour éviter la présence littérale de la chaîne "Assembly.Load" dans le binaire.
  5. Une fois l'assembly chargé en mémoire, un helper générique (types[25] de l'assembly, indexation numérique plutôt que par nom) cherche une méthode statique sans paramètre et l'invoque : c'est le point d'entrée du stage 2.

Résultat vérifié (script tools/decrypt_stage2_resource.py) : le déchiffrement donne un PE32 .NET DLL valide (magic MZ), de 459 024 octets. C'est la taille de la ressource source, ce qui correspond à un AES sans padding supplémentaire visible.

SHA256 (stage 2) : 39fdba7a439cb09842f26d34f84606e3cc7f685b407deceb49ee7cb71271ebcd

Vérification sur MalwareBazaar : get_info sur ce hash renvoie hash_not_found. Ce stage 2 n'était pas indexé publiquement au moment de l'analyse, donc aucun rapport tiers à croiser à ce stade (pour StealC, la confirmation externe existait dès le départ). Je continue seul sur ce terrain jusqu'à la confirmation externe du §10.


5. Stage 2 : identification du protecteur (Eziriz .NET Reactor)

Le stage 2 (la DLL .NET extraite et déchiffrée au §4.5) fait environ 459 Ko et se décompile en 18 571 lignes de C# avec ilspycmd. capa y trouve 14 capacités, dont check for debugger via API, manipulate unmanaged memory in .NET et unmanaged call. La couche d'évasion et de manipulation de mémoire native est donc plus poussée que dans le stage 1.

5.1 Une chaîne en clair donne le nom du protecteur

Pour le stage 2, pas besoin d'hypothèse : une chaîne en clair dans le code décompilé nomme le protecteur :

"This assembly is protected by an unregistered version of Eziriz's ".NET Reactor"! This assembly won't further work."

C'est le message de licence trial d'Eziriz .NET Reactor, un protecteur commercial .NET qui n'a rien à voir avec ConfuserEx. La chaîne se trouve dans un case du dispatcher principal (voir §6), exécuté quand l'assembly détecte qu'il tourne sans licence valide.

Une remarque : l'obfuscation du stage 1 (identifiants aléatoires courts) et celle du stage 2 (identifiants en mots anglais plausibles, AdjustableSchema, SchemaDecryptor, LocatorDecryptor...) ne se ressemblent pas. Je n'ai pas établi si le stage 1 utilise le même protecteur avec un autre réglage de renommage, ou un protecteur différent. La question reste ouverte, elle n'est pas nécessaire pour la suite.

Cette identification sera confirmée de façon indépendante au §10 par le tag netreactor d'ANY.RUN, issu d'une classification automatique tierce, sans lien avec ma lecture du code.


6. Control-flow flattening réel : ampleur et outils construits

6.1 Un vrai flattening, pas un motif « 1 branche réelle + leurres »

Dans le script AutoIt de StealC, chaque Switch n'avait qu'une branche réellement exécutée, le reste étant du code mort. Le stage 2 est différent : il utilise un control-flow flattening classique et complet. Chaque case d'un dispatcher switch(état) est du vrai code, chaîné au suivant par des réaffectations de la variable d'état. Il y a aussi de vrais branchements conditionnels : un if/else dans un case peut mener à deux états suivants différents.

Échelle mesurée :

AsgardProtector (StealC, AutoIt) .NET Reactor (AgentTesla, stage 2)
Blocs Switch/dispatcher 354 79 (dont 24 avec ≥5 cases, les vrais dispatchers)
Cases au total ~354 (1 par bloc) 1 891
Plus gros bloc N/A 747 cases, 4 621 lignes : à lui seul plus gros que la totalité des 354 blocs AsgardProtector cumulés

6.2 Outils construits

Chez StealC, la question était « quelle est la seule branche réelle ? », et l'arithmétique suffisait. Ici, il faut reconstruire un vrai graphe de flot de contrôle, avec des branchements. J'ai écrit deux scripts, à utiliser dans cet ordre :

  • deflatten_cs.py : parse chaque switch(var) { case N: ... }, extrait pour chaque case son (ou ses) état(s) suivant(s) (default_edge inconditionnel, branch_edges conditionnels), et le classe : linéaire (1 seul successeur), branchant (plusieurs), terminal (sort du dispatcher), ou donnée-dépendant (le prochain état dépend d'une lecture runtime, non résolvable statiquement).
  • linearize_cs.py : parcourt le graphe extrait depuis un point d'entrée détecté (recherche arrière du littéral qui initialise la variable d'état), et émet les blocs en ordre de visite (DFS itératif, pas récursif, certaines chaînes dépassent la limite de récursion Python), avec les arêtes explicitées en commentaire plutôt que noyées dans un switch unique.

Les deux scripts sont volontairement génériques : ils traitent n'importe quel switch(var) du fichier, pas seulement le plus gros. Je les réutilise au §8 pour explorer plusieurs points d'entrée.

6.3 Deux détails que les outils doivent gérer

Les goto case/goto label. Chercher les réaffectations littérales var = N; ne suffit pas. Le plus petit bloc du fichier (6 cases, celui qui contient le message « .NET Reactor » du §5.1) contient aussi des goto case 0; et des goto end_IL_0014;, qui sont de vraies branches. deflatten_cs.py traite donc goto case N comme une arête CASE et goto Label comme une arête de sortie EXIT.

J'ai comparé ce bloc de 6 cases ligne à ligne avec le source :

case [None] (default): default=('EXIT','return') branch=[('EXIT','goto end_IL_0014'), ('CASE', 0)]
case [3]: default=('CASE', 10) branch=[('CASE', 1)]
case [0]: default=('EXIT','throw') branch=[] TERMINAL

Ça correspond exactement au code relu à la main.

Les copies avec préfixe de type, loin du switch. Le détecteur de point d'entrée (find_entry_value) remonte une chaîne de copies (num3 = num2; num2 = num;) jusqu'au littéral initial. Il doit accepter les déclarations (int num2 = num;), qui commencent par un type, et pas seulement la réaffectation nue (num2 = num;). Il doit aussi chercher assez loin : sur le plus gros dispatcher, le littéral (int num = 299;) est à 114 lignes du switch. La recherche est donc bornée par la signature de la méthode englobante (on remonte jusqu'à la déclaration de méthode la plus proche), pas par un nombre fixe de lignes.

6.4 Couverture obtenue

Couverture (linearize_cs.py) sur les 3 plus gros dispatchers, avec la seule entrée 299, le littéral qui initialise la variable d'état :

Switch (ligne source) Cases États atteints depuis l'entrée 299
6660 377 301/379 (79%)
8965 (suspecté : logique d'injection) 649 415/673 (62%)
15073 388 302/397 (76%)

Les deux outils ne comptent pas la même chose pour le bloc de la ligne 8965 : deflatten_cs.py y relève 747 case (voir runbook.md), linearize_cs.py en retient 649. Toute la suite de l'analyse utilise 649.

Les petits dispatchers (5-45 cases, des méthodes utilitaires) sont bien moins couverts. C'est sans doute parce que leur variable d'état est initialisée à partir d'un paramètre de méthode, et non d'un littéral local que cette heuristique sait suivre. Le §8 contourne cette limite.


7. La séquence d'injection du template, inactive sur ce build

7.1 La séquence, avec arguments réels

Les API Windows sont résolues dynamiquement (LoadLibrary+GetProcAddress), avec des noms découpés en morceaux pour ne jamais apparaître tels quels, par exemple "Write ".Trim() + "Process ".Trim() + "Memory". En remontant les trampolines qui appellent ces wrappers, on voit la séquence apparaître, avec ses vrais arguments, dans le flux reconstruit du dispatcher à 649 cases :

Étape Appel réel retrouvé Signification
Recherche ressource ExecuteSharer(..., "__", 10u) FindResourceA(module, "__", RT_RCDATA)
Ouverture process DestroyDetailedRole(56u, 1, pid) OpenProcess(0x38, TRUE, pid) : 0x38 = PROCESS_VM_OPERATION\|PROCESS_VM_WRITE\|PROCESS_VM_READ, droits minimaux d'injection
Allocation InstantiateSegmentedStrategy(IntPtr.Zero, taille, 4096u, 64u) VirtualAlloc(NULL, taille, MEM_COMMIT, PAGE_EXECUTE_READWRITE) : allocation locale
Écriture StructAdvancedQueue(intPtr7, intPtr6, buffer, 4u, out param) WriteProcessMemory(hProcess, addr, buffer, 4, ...)
Protection ValidateDetachedFunction(...) (~8 occurrences, flags alternés) VirtualProtect : cycle RW/RX cohérent avec un patch mémoire
Nettoyage InstantiateTransformableFinalizer(intPtr7) CloseHandle(intPtr7)

Réserve : cet ordre est l'ordre de visite du graphe par le parcours DFS, pas forcément l'ordre d'exécution réel sur un run donné (le graphe a de vrais branchements). Voir aussi le §12.

7.2 Cette séquence dépend d'une ressource qui n'existe pas

D'où vient le pid ? Je l'ai tracé jusqu'au bout :

internal static object StoreConfiguration() { return Process.GetCurrentProcess(); }
internal static object ManageConsumer(object P_0) { return ((Process)P_0).MainModule; }
internal static IntPtr DestroyConcreteObserver(object P_0) { return ((ProcessModule)P_0).BaseAddress; }

Le pid passé à OpenProcess est celui du process courant, et la ressource "__" est cherchée dans le module de base de ce même process. La séquence du §7.1 est donc une auto-injection : le stage 2 s'injecte dans son propre process. Mais elle est conditionnelle. La fonction qui consomme le résultat de FindResourceA (DestroySegmentedProxy(P_0, P_1)) est un simple test d'inégalité P_0 != P_1, et le code se lit ainsi :

if (FindResourceA(moduleBase, "__", RT_RCDATA) == IntPtr.Zero)  // ressource absente
{
    num = 28;  // nouvel etat de la boucle EXTERNE
    break;
}
// sinon seulement : suite de la sequence d'auto-injection ci-dessus

J'ai vérifié avec pefile sur les deux binaires : le stage 1, qui est le vrai MainModule du process puisque le stage 2 est chargé en mémoire sans fichier sur disque, et le stage 2 lui-même. Aucun des deux ne contient de ressource native RT_RCDATA nommée "__". On n'y trouve que des ressources standard RT_ICON/RT_GROUP_ICON/RT_VERSION.

Conclusion : la séquence d'auto-injection ci-dessus est du vrai code du template du crypter, sans doute activé sur d'autres builds ou configurations, mais cet échantillon ne l'emprunte jamais. Le vrai chemin d'exécution part vers l'état 28 de la boucle externe. Je le suis au §8.


8. Le vrai chemin d'exécution et couverture quasi-complète (97%)

8.1 L'état 28 mène à une ressource qui existe réellement

num = 28; break; (le repli quand FindResourceA("__") échoue) réinjecte num3 = 28 au tour suivant de la boucle externe. C'est le même dispatcher qu'aux §6-7, pas une autre machine à états : rentrer dans la triple boucle while(true){ int num2 = num; while(true){ int num3 = num2; while(true){ switch(num3)... réamorce simplement num3 avec la nouvelle valeur de num.

En partant de l'entrée 28, le parcours couvre 417/673 états. Le point clé est à l'état 30 :

schemaMember = new SchemaMember((Stream)CompareAdvancedWatcher(schemaEncryptor, "ObjectRequester.ScopeObject"));

"ObjectRequester.ScopeObject" est l'une des 4 vraies ressources .NET managées du stage 2. ilspycmd --list-resources les liste : ObjectRequester.ScopeObject, ScheduledObject.ObjectParser, DecryptorCompressor.ControllableObject, AuditorMap.LocalObject. La ressource native "__" du §7, elle, n'existe dans aucun des deux binaires. C'est donc ici que se trouve le vrai accès au payload.

Tracé également : - schemaEncryptor = Type.GetTypeFromHandle(...).Assembly : l'assembly courant (stage 2 lui-même), obtenu par un détour pour éviter l'appel littéral Assembly.GetExecutingAssembly(). - La classe SchemaMember n'est qu'un BinaryReader (ReadBytes/Read/ReadInt32/Close) : aucun déchiffrement dedans, juste la lecture séquentielle brute du flux. - Les 4 ressources extraites (92 Ko, 132 Ko, 4 Ko, 256 octets) sont toutes à haute entropie, sans en-tête reconnaissable, chiffrées.

8.2 Couverture quasi-complète par cascade de points d'entrée

Constat : un case marqué « terminal » qui réaffecte la variable externe num (et non num3) n'est pas une sortie. C'est un nouveau point d'entrée dans le même dispatcher, pas une autre machine à états à modéliser.

J'ai donc écrit un script de point fixe : il explore depuis {299, 28}, relève chaque nouvelle valeur de num trouvée dans un nœud terminal, l'ajoute comme point d'entrée et recommence jusqu'à stabilisation.

Résultat : 653/673 états couverts (97%), contre 415/673 avec la seule entrée 299 (§6.4). La cascade fait apparaître 79 points d'entrée distincts. Il ne reste que 20 états inatteignables depuis un point d'entrée connu.

Il n'a pas fallu de nouvel outil pour ça : ce sont linearize_cs.py/deflatten_cs.py, appliqués en boucle au lieu d'une seule fois.

8.3 Ce qui bloque le déchiffrement final : un format dispersé, pas une boucle isolable

Les lectures de schemaMember (par InstantiatePassiveDecider(schemaMember) = BinaryReader.ReadInt32()) sont dispersées sur 18-20 états du graphe. Certaines lisent une valeur et la jettent sans l'assigner. Ça ressemble à un format d'enregistrement maison où certains champs sont simplement sautés. Ce n'est pas une boucle de déchiffrement qu'on pourrait isoler et rejouer.


9. Deux tentatives d'automatisation du déchiffrement final

9.1 Vérification publique avant d'investir davantage d'heures à la main

Avant de continuer à la main, j'ai vérifié ce qui est public sur le fonctionnement interne de .NET Reactor. C'est confirmé : .NET Reactor stocke son bytecode de VM et ses métadonnées de protection directement dans des ressources d'assembly (source). Le pattern SchemaMember/BinaryReader/ressources nommées que j'avais tracé est donc très probablement le mécanisme générique de .NET Reactor, pas un mécanisme de livraison propre à ce malware. En continuant à la main, j'aurais revalidé le fonctionnement d'un produit commercial déjà documenté, sans avancer sur le malware.

9.2 Première tentative : NETReactorSlayer (binaire officiel)

Il existe un outil dédié à ce protecteur : NETReactorSlayer (GPLv3, il supporte .NET Reactor 6.0-6.9), avec un build natif Linux x64, donc sans Wine. Je l'utilise uniquement en lecture et transformation statique. Le binaire cible n'est jamais exécuté.

Résultat, NETReactorSlayer.CLI stage2.dll :

751 Methods decrypted.
999 Equations resolved.
Anti tamper removed.
Anti debugger removed.
[ERROR] An unexpected error occurred during decrypting resources. Object reference not set to an instance of an object.
185 Calls to obfuscator types removed.
567 Methods inlined.

Progrès réel et global (méthodes déchiffrées, anti-tamper/anti-debug retirés, nettoyage), mais : - Le déchiffrement des ressources échoue (exception dans l'outil) : ObjectRequester.ScopeObject reste chiffrée. - La méthode d'injection elle-même reste flattened après nettoyage : toujours 26 while(true) et plusieurs switch imbriqués dans son décompilé. Le fichier nettoyé est même plus long que l'original (25 353 lignes contre 18 571) : l'inlining de 567 méthodes a dilué le compte de lignes plutôt que de le réduire.

9.3 Deuxième tentative : compilation depuis la source, avec un correctif jamais publié

J'ai cherché le bug précis dans l'historique Git du dépôt officiel et trouvé un commit "Improve resource decrypter. Resolve #54", daté du 15/12/2022, soit 3 jours après la release v6.4.0.0 utilisée à l'étape précédente. Ce correctif n'a jamais été intégré à un binaire publié (aucune release depuis).

Je l'ai compilé depuis la source (git clone + dotnet build -f net6.0), puis exécuté avec DOTNET_ROLL_FORWARD=LatestMajor pour qu'il tourne sur le runtime .NET 10 installé, faute de runtime 6.0. Même erreur, exactement, malgré le correctif.

Diagnostic en lisant le code source (NETReactorSlayer.Core/Stages/ResourceResolver.cs) : ce stage vise un pattern précis, l'assembly .NET Reactor stocké en ressource et chargé par un handler AppDomain.AssemblyResolve. Il cherche une méthode de signature (object, ResolveEventArgs) ou (object, object). Or ce sample fait autre chose : SchemaMember lit directement GetManifestResourceStream depuis le code flattened, sans jamais passer par AssemblyResolve. Le Find() de l'outil matche en partie, assez pour se déclencher, puis échoue. Ce n'est pas un bug ponctuel à corriger : l'outil cible une autre fonctionnalité de .NET Reactor que celle utilisée ici.

9.4 Bilan des deux tentatives

Acquis, vérifiés : - Vrai chemin d'exécution identifié (entrée 28, pas 299), menant à une ressource .NET réellement présente - 97% du graphe de la fonction d'injection couvert et vérifié (653/673 états) - Deux tentatives d'automatisation du déchiffrement final documentées, avec diagnostic précis de leurs limites respectives : l'une par excès de dispersion du format (outil maison), l'autre par mécanisme ciblé différent du bon (outil spécialisé)

Ouvert à ce stade : le contenu déchiffré de ObjectRequester.ScopeObject (92 Ko), probable payload final. Fermé autrement en §10.


10. Confirmation externe et infrastructure de campagne

La question qui reste : que contient vraiment le payload final (ObjectRequester.ScopeObject, 92 Ko chiffrés), après deux tentatives d'automatisation ratées (§9) ? Plutôt que de m'acharner sur une voie technique bloquée, je ferme la question par recoupement OSINT. C'est la même logique que dans la partie 4 de StealC : prendre la preuve la plus fiable disponible.

Précision de méthode, comme pour StealC : à partir d'ici, la confirmation s'appuie sur l'exécution de cet échantillon exact par des tiers (les sandboxes ANY.RUN, Triage, VMRay), pas sur une exécution de ma part. Les sections précédentes (§1 à §9) restent strictement statiques.

10.1 Ce que l'API MalwareBazaar contenait déjà

Une lecture complète de get_info sur le hash exact du stage 1 (le champ vendor_intel, et pas seulement le hash et les tags comme aux étapes précédentes) donne la liste des sandboxes tierces qui ont traité cet échantillon : ANY.RUN, Triage, VMRay, CAPE, UnpacMe, Intezer, Kaspersky, ReversingLabs, entre autres.

Confirmation indépendante de la famille : ANY.RUN, Triage et VMRay classent tous les trois l'échantillon en agenttesla, indépendamment du tag communautaire MalwareBazaar. La réserve du §2 est levée.

Tags ANY.RUN : netreactor, stealer, ip-check, evasion, agenttesla, ftp, exfiltration. Le tag netreactor confirme, de façon totalement indépendante, l'identification du protecteur faite en §5.1 via une chaîne trouvée en clair dans le code : deux méthodes complètement différentes (lecture de code vs classification automatique tierce) aboutissent à la même conclusion.

10.2 Configuration extraite dynamiquement par Triage

Rapport complet : tria.ge/reports/260714-kt4y4scs2z

Exfiltration : FTP
Hôte         : ftp.piovau.com:21
Identifiants : présents en clair dans le rapport de sandbox

Comportements confirmés par l'exécution dynamique : - Accès aux profils Microsoft Outlook : confirme un vol de données réel, pas seulement une capacité théorique déduite du code statique - Consultation d'adresse IP externe via un service web : ça colle avec le tag ip-check d'ANY.RUN - Usage de SetThreadContext, signalé explicitement par le sandbox

10.3 La suite de la chaîne, vue par la sandbox

SetThreadContext n'apparaît nulle part dans mon traçage statique du stage 2 (§6 à §8). La séquence que j'ai tracée s'arrête à OpenProcess/VirtualAlloc/WriteProcessMemory/VirtualProtect, sans jamais atteindre SetThreadContext/ResumeThread. Ce n'est pas une contradiction, c'est la suite de la chaîne :

Stage 2 (tracé statiquement, §6-8)
  → prépare/charge une image en mémoire locale RWX, écrit dans un process distant
    → payload final (jamais extrait manuellement, dans la ressource chiffrée)
      → exécute le hollowing classique par détournement de contexte de thread (SetThreadContext, vu par le sandbox)

C'est exactement le pattern de StealC (process hollowing par section mappée), un cran plus loin dans la chaîne. Le hollowing « classique » n'est pas dans le stage que j'ai pu lire. Il est dans celui que je n'ai pas pu déchiffrer, et seule une exécution réelle, faite par un tiers, le montre.

10.4 UnpacMe : probable confirmation directe du contenu déchiffré

UnpacMe référence 5 hashs liés au même run d'unpacking automatique sur mon échantillon, dont un avec des détections antivirus directes :

SHA256 : 352ce702aafbbb7253872bfb6c55ab6d7bba6a4cc83cce427fb4c863331ef566
Détections : win_agent_tesla_g2, triage_agenttesla_infostealer

C'est probablement le contenu déchiffré de la ressource que je cherchais à extraire à la main aux §8-9. Je ne l'ai pas confirmé formellement : la page UnpacMe est rendue en JavaScript et ne se consulte pas par simple récupération de page. La piste reste ouverte s'il faut une confirmation plus directe (télécharger le hash lui-même sur MalwareBazaar, ou passer par l'API UnpacMe si elle est publique).


11. Conclusion générale

CTM.exe (leurre : outil de dimensionnement de structure navale, MalwareBazaar 2026-07-14)
  → ressource .NET chiffrée (inversion + AES-128, clé=IV en dur)
    → stage 2 : protégé Eziriz .NET Reactor (confirmé en clair, puis indépendamment par ANY.RUN)
      → control-flow flattening réel (1 891 cases), outils dédiés construits et validés à la main
        → séquence d'auto-injection du template : inactive sur ce build (ressource absente)
          → vrai chemin tracé à 97% : OpenProcess/VirtualAlloc/WriteProcessMemory/VirtualProtect
            → payload final chiffré (non extrait manuellement)
              → AgentTesla confirmé (3 sandboxes indépendantes), hollowing classique (SetThreadContext, vu dynamiquement)
                → exfiltration FTP vers ftp.piovau.com, vol de données Outlook

La question « que contient le payload final ? » est fermée par recoupement OSINT, pas par un déchiffrement manuel complet. C'est la méthode déjà appliquée à StealC : deux tentatives sérieuses et documentées (outil maison, outil spécialisé) plutôt qu'un acharnement sans fin sur une voie bloquée, puis une confirmation externe indépendante quand elle existe.

12. Limites

  • Je n'ai jamais déchiffré moi-même le contenu de ObjectRequester.ScopeObject. Il est seulement recoupé par des sandboxes tierces (§10.4).
  • La séquence d'injection (§7.1) reflète un ordre de visite du graphe, pas un ordre d'exécution confirmé sur un run réel.
  • Le protecteur du stage 1 (§4.2) n'a jamais été confirmé. Seul celui du stage 2 l'est, par une preuve directe (§5.1) puis par une confirmation tierce indépendante (§10.1).
  • Je n'ai fait aucune vérification passive indépendante (type ThreatFox) de l'infrastructure FTP (ftp.piovau.com). La piste reste ouverte s'il faut confirmer son activité.
  • 20 états du dispatcher principal (§8.2) restent inatteignables depuis tout point d'entrée connu. C'est probablement du code mort, mais je ne l'ai pas confirmé.

13. IOCs consolidés

Type Valeur
SHA256 (stage 1, CTM.exe) bc6d86cef1b7404823c1d830387b2c9b1289c453620482fc1749dd5d2ade3897
SHA256 (stage 2, DLL déchiffrée) 39fdba7a439cb09842f26d34f84606e3cc7f685b407deceb49ee7cb71271ebcd (non indexé sur MalwareBazaar)
SHA256 (probable payload final déchiffré, via UnpacMe) 352ce702aafbbb7253872bfb6c55ab6d7bba6a4cc83cce427fb4c863331ef566
Ressource stage1→stage2 Genitalk.klaoxao.tiff (459 024 octets)
Clé/IV AES-128 (stage1→stage2) e4a931cb6e204322da0d1a30d946633b
Ressource stage2→payload ObjectRequester.ScopeObject (92 Ko, chiffrée, jamais déchiffrée manuellement)
Autres ressources stage 2 ScheduledObject.ObjectParser (132 Ko), DecryptorCompressor.ControllableObject (4 Ko), AuditorMap.LocalObject (256 o)
Protecteur (stage 2) Eziriz .NET Reactor (chaîne de licence trial en clair + confirmation ANY.RUN)
C2 exfiltration FTP ftp.piovau.com:21
Famille AgentTesla (confirmée ANY.RUN + Triage + VMRay)
Rapport Triage tria.ge/reports/260714-kt4y4scs2z

14. Reproduire l'analyse

Le journal détaillé (commande, raison, sortie brute, ce que j'en retiens) de chacune des quatre parties est dans runbook.md, qui couvre :

Prérequis : pefile, capa+règles, ilspycmd, .NET SDK 10.0, pycryptodome, une clé API MalwareBazaar (voir ../stealc-autoit-killchain/runbook.md pour l'installation de la base commune).