# 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`](writeups/agenttesla-netreactor-killchain/runbook.md), organisé selon les mêmes quatre parties. Les outils autonomes sont dans [`tools/`](tools.html?slug=agenttesla-netreactor-killchain).

---

## 1. Contexte et choix de l'échantillon

C'est la suite directe de l'enquête StealC (voir [`../stealc-autoit-killchain/`](writeups/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](https://bazaar.abuse.ch/). 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`](https://github.com/mandiant/capa) 9.4.0 + règles officielles, pour la détection de capacités
- [`ilspycmd`](https://github.com/icsharpcode/ILSpy) 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

```csharp
[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`](writeups/agenttesla-netreactor-killchain/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`](writeups/agenttesla-netreactor-killchain/tools/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`](writeups/agenttesla-netreactor-killchain/tools/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`](writeups/agenttesla-netreactor-killchain/runbook.md#partie-2--identification-du-protecteur-et-control-flow-flattening)), `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 :

```csharp
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 :

```csharp
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 :

```csharp
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](https://deepwiki.com/void-stack/VMAttack/5.1-eziriz-.net-reactor-analysis)). 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**](https://github.com/SychicBoy/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](https://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](https://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`](writeups/agenttesla-netreactor-killchain/runbook.md), qui couvre :

- [Partie 1 : loader stage 1](writeups/agenttesla-netreactor-killchain/runbook.md#partie-1--loader-stage-1) (§4 ci-dessus)
- [Partie 2 : identification du protecteur et control-flow flattening](writeups/agenttesla-netreactor-killchain/runbook.md#partie-2--identification-du-protecteur-et-control-flow-flattening) (§5 à 7)
- [Partie 3 : vrai chemin et tentatives d'outillage](writeups/agenttesla-netreactor-killchain/runbook.md#partie-3--vrai-chemin-et-tentatives-doutillage) (§8 à 9)
- [Partie 4 : confirmation externe](writeups/agenttesla-netreactor-killchain/runbook.md#partie-4--confirmation-externe) (§10)

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