Gant de sécurité offensif 2025 - L'Ombre des voleurs
L'Ombre des Voleurs
Cette année, une partie du CTF d'OffSec impliquait une enquête médico-légale sur une machine compromise.
On nous a donné l'invite suivante :
Download the ZIP package. The password is "**Shadow234@**".
Thanks to your actions during the ProtoVault incident, you've gained the trust of the Etherians. The OffSec Legend, Cipherflare has called upon you to investigate the breach before more damage is done.
The Etherians offer fragments of evidence, just enough to begin the investigation:
- The user directory of **[email protected]** from the machine **WK001**
- Event logs from **WK001**
A ZIP archive awaits you.
Uncover the truth hidden in the darkness. Find what was taken, and how.
À l'intérieur du zip, nous avons quelques fichiers-
dans le répertoire a.smith, nous obtenons le dossier personnel de l'utilisateur

Alors que le fichier .evtx est un journal système pour ce système.
Partie 1 - Résumé par souci de concision
J'utilise evtx_dump pour analyser le fichier pour plus de lisibilité :
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% ~/Tools/evtx_dump -o jsonl -f logs_json.json logs.evtx
Il peut également être ouvert dans l'observateur d'événements Windows, mais il est beaucoup plus facile à analyser via CLI

Nous pouvons utiliser jq pour filtrer et affiner notre recherche
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% cat logs_json.json | jq '.Event.System["EventID"]' | head -n 50
23
11
11
11
11
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% cat logs_json.json | jq '.Event | select(.System["EventID"] == 3)' | head -n 50
{
"#attributes": {
"xmlns": "http://schemas.microsoft.com/win/2004/08/events/event"
},
"System": {
"Provider": {
"#attributes": {
"Name": "Microsoft-Windows-Sysmon",
"Guid": "5770385F-C22A-43E0-BF4C-06F5698FFBD9"
}
},
"EventID": 3,
"Version": 5,
"Level": 4,
"Task": 3,
"Opcode": 0,
"Keywords": "0x8000000000000000",
"TimeCreated": {
"#attributes": {
"SystemTime": "2025-07-31T08:56:30.629677Z"
}
},
"EventRecordID": 41617,
"Correlation": null,
"Execution": {
"#attributes": {
"ProcessID": 6648,
"ThreadID": 10256
}
},
"Channel": "Microsoft-Windows-Sysmon/Operational",
"Computer": "WK001.megacorpone.com",
"Security": {
"#attributes": {
"UserID": "S-1-5-18"
}
}
},
"EventData": {
"RuleName": "-",
"UtcTime": "2025-07-31 08:56:50.550",
"ProcessGuid": "00000000-0000-0000-0000-000000000000",
"ProcessId": 11360,
"Image": "<unknown process>",
"User": "-",
"Protocol": "tcp",
"Initiated": true,
"SourceIsIpv6": false,
"SourceIp": "10.10.10.245",
"SourceHostname": "WK001.megacorpone.com",
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% jq -c 'select(.Event.System.EventID == 3 25-10-14 - 14:46:29
and .Event.EventData.Initiated == true
and .Event.EventData.Image != "C:\\Windows\\System32\\svchost.exe"
and .Event.EventData.Image != "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
and .Event.EventData.Image != "C:\\Windows\\System32\\taskhostw.exe")' logs_json.json | wc -l
15981
Finalement, (en gagnant du temps en sautant une grande partie de ce défi) nous tombons sur cet événement :
},
"EventData": {
"RuleName": "-",
"UtcTime": "2025-08-05 09:02:06.865",
"ProcessGuid": "8404BF77-C85E-6891-0E37-000000000C00",
"ProcessId": 17852,
"User": "MEGACORPONE\\a.smith",
"Image": "C:\\Users\\a.smith\\AppData\\Local\\Microsoft\\Windows\\INetCache\\IE\\66HCZK0X\\captcha_privacy[1].epub",
"TargetFilename": "C:\\Users\\a.smith\\AppData\\Local\\Temp\\101010245WK001.zip",
"Hashes": "SHA1=756FF3A252D10493CE9C34297FA7BB6F84DC27A4,MD5=053C53EC53D5E6C720AB105BC46FAE2B,SHA256=B6A1646F23BA0A05B7C80A7D6261204384AB06F15983EB195EB5F0A3FEDF2475,IMPHASH=00000000000000000000000000000000",
"IsExecutable": false,
"Archived": "true"
}
}
}
Ce qui est étrange - Si vous remarquez, il s'agit d'un événement de création de fichier et le champ Image est un fichier epub, ce qui est extrêmement anormal. Cela nous amène à enquêter sur ce fichier epub en question. Comme vous vous en doutez, le fichier epub est en fait un PE32 qui, en raison d'une modification malveillante du registre, est exécuté en tant qu'exécutable.
C'était un résumé de plusieurs heures de recherche de miettes dans les journaux d'événements et le profil utilisateur, mais l'objectif principal de cet article de blog est le démontage.
Partie 2 - Démontage des fichiers malveillants
Nous téléchargeons ce fichier sur Ghidra pour le démontage et vérifions immédiatement la fonction de saisie. Cette fonction d'entrée appelle simplement deux fonctions,
Le premier n’a rien d’intéressant, juste un passe-partout

La suivante est une fonction d'injection utilisée pour préparer les données

Dans le rouge, on voit une configuration. Mais ce qui est intéressant, c'est le bleu et ce qu'il y a en dessous.
On voit ces lignes :

uVar8 = _get_initial_narrow_environment();
puVar9 = (undefined8 *)__p___argv();
uVar1 = *puVar9;
puVar10 = (undefined4 *)__p___argc();
iVar5 = FUN_14002a820(*puVar10,uVar1,uVar8);
cVar3 = FUN_140374c9c();
if (cVar3 != '\0') {
if (!bVar2) {
_cexit();
}
__scrt_uninitialize_crt(1,0);
return iVar5;
}
On dirait qu'il configure des variables, puis appelle une fonction, en transmettant ces variables. Dans ce cas, FUN_14002a820 est appelé et reçoit 3 arguments. Ensuite, il semble que FUN_140374c9c soit appelé et que la sortie soit vérifiée pour une sortie réussie (sans erreur). Ensuite, la valeur de retour de la fonction FUN_14002a820 est renvoyée.
Pour faciliter le suivi, nous renommerons FUN_14002a820 en wrapper_main, puis suivrons la xréf.
On dirait que wrapper_main est lui-même un wrapper pour un autre FUN_1402cef40. Nous allons renommer celui-ci en wrapper_2. Nous voyons également quelques paramètres supplémentaires être transmis, mais nous y reviendrons plus tard. Voyons ce qu'il y a à l'intérieur de wrapper_2
Ici, nous avons beaucoup plus à travailler-
Je vais vous épargner des maux de tête et vous expliquer simplement ce que cela fait. Tout d’abord, il configure la pile et prépare la gestion des erreurs :
AddVectoredExceptionHandler(0, FUN_1402e1550);
local_90 = (undefined *)CONCAT44(local_90._4_4_, 0x5000);
SetThreadStackGuarantee((PULONG)&local_90);
Ensuite, il initialise un thread :
pvVar3 = GetCurrentThread();
(*(code *)PTR_FUN_14054bd70)(pvVar3,"m");
Les lignes suivantes font plusieurs choses : premièrement, elles configurent l'accès à TLS (Thread Local Storage) via le segment GS. Ensuite, il configure une boucle pour incrémenter DAT_14054bff0 avec verrouillage et la réécrit à l'emplacement TLS. En fait, cela ne fait que enregistre le thread dans un contexte d'exécution global et garantit une initialisation thread-safe.
lVar5 = *(longlong *)
(*(longlong *)(*(longlong *)(unaff_GS_OFFSET + 0x58) + (ulonglong)_tls_index * 8) + 0xe8)
;
lVar4 = DAT_14054bff0;
if (lVar5 == 0) {
do {
if (lVar4 == -1) {
FUN_140383ec0();
do {
invalidInstructionException();
} while( true );
}
lVar5 = lVar4 + 1;
LOCK();
bVar6 = lVar4 != DAT_14054bff0;
lVar1 = lVar5;
if (bVar6) {
lVar4 = DAT_14054bff0;
lVar1 = DAT_14054bff0;
}
DAT_14054bff0 = lVar1;
UNLOCK();
} while (bVar6);
*(longlong *)
(*(longlong *)(*(longlong *)(unaff_GS_OFFSET + 0x58) + (ulonglong)_tls_index * 8) + 0xe8) =
lVar5;
}
Voici maintenant la partie importante
iVar2 = (**(code **)(param_2 + 0x28))(param_1);
Nous avons déjà vu cela, mais celui-ci nécessite une petite compréhension des langages de programmation de bas niveau. Ce que fait cette ligne de code, c'est essentiellement de trouver la donnée localisée par le param_2 (agissant comme un pointeur), et d'y ajouter le décalage 0x28. Il récupère toutes les données correspondant à ce point et l'appelle en tant que fonction, en passant param_1 comme argument.
C'est une manière longue et interminable d'expliquer que c'est très obscur, et que nous n'avons même pas encore vu la véritable fonction « principale ».
Pour le trouver, nous devrons découvrir à quoi param_2 + 0x28 se résout, ce qui signifie trouver param_2
Eh bien, rappelez-vous que nous en sommes déjà à 3 emballages. Vérifions donc simplement le wrapper au-dessus de celui-ci pour savoir d'où vient le paramètre :
Ici, nous voyons que &DAT_14038c518 est passé comme param_2. Nous pouvons accéder à ce pointeur en double-cliquant dessus, et cela nous amène à l'endroit où ces données sont stockées.

On dirait que c'est juste remis à zéro. Heureusement, nous ne recherchons pas cela directement, mais il s'agit simplement d'une base à laquelle nous compenserons par notre 0x28. Donc, ce que nous pouvons faire est d'appuyer sur G et d'entrer l'adresse de DAT_14038c518, et d'ajouter notre décalage.

Et nous avons trouvé une adresse mémoire à cet endroit ! Maintenant, grâce à Ghidra, cela s'affiche sous la forme FUN_1400583d0, nous savons donc qu'il y a une fonction ici.
Entrons dans cette fonction et renommez-la en main pendant que nous y sommes.

Excellent, un autre emballage ! Allons-y et suivons FUN_14004eea0
Qu'est-ce que c'est ? Un autre emballage !? Super. Mais nous sommes désormais à la croisée des chemins. Cet emballage est différent. Ce qu'il fait, c'est essentiellement prendre toutes les données stockées dans param_1, les interpréter comme une adresse mémoire et exécuter tout ce qui s'y trouve. Il va donc encore une fois falloir retracer l’origine du paramètre.
En d'autres termes, FUN_14004eea0(code *param_1) (*param_1)(); appelle la fonction dont l'adresse est dans param_1.
En regardant simplement le code C, nous ne serions pas en mesure de déterminer d'où vient cette variable. Cependant, quand on regarde les instructions démontées, on voit que la valeur est simplement stockée dans le registre RCX-

Et en effet, la couche wrapper précédente préparait ce registre à être lu :

Maintenant, nous sommes un peu au point mort - nous devons trouver ce qu'il y a dans le registre RCX, mais ceux-ci n'existent qu'au moment de l'exécution, nous devrons donc déboguer le programme et définir un point d'arrêt à cet emplacement, ce qui devrait être facile puisque nous avons l'adresse de la fonction à laquelle nous voulons interrompre.

Parce que ce programme est un exécutable PE32 et non un binaire ELFE, nous ne pouvons pas utiliser Ghidra ou gdb pour le déboguer (mes programmes de choix pour ce type de travail), mais nous aurons plutôt besoin d'un débogueur Windows. Mon débogueur Windows préféré est x64dbg.
Le travail que nous devons effectuer dans le débogueur est assez minime : nous cherchons simplement à inspecter la mémoire à un certain point d'arrêt. Je ne vais donc pas trop expliquer ici et je me contenterai de passer en revue les détails de haut niveau.
La première chose à remarquer, ce sont les différentes adresses sur la gauche. Pour cette raison, nous ne pouvons pas simplement définir un point d'arrêt à 0x14004eea0. La raison de cette différence réside dans la manière dont le programme est chargé.
- Ghidra analyse le fichier de manière statique. Il utilise le base d'images enregistré dans l'en-tête PE (par exemple
0x140000000) comme début de toutes les adresses virtuelles. - x64dbg affiche les adresses après que Windows ait chargé le programme. Le chargeur de Windows applique Randomisation de la disposition de l'espace d'adressage (ASLR), il mappe donc l'image sur un base aléatoire (par exemple
0x00007FF6C3E51000).

Comme nous ne pourrons pas le faire de manière simple, nous pouvons simplement utiliser des décalages relatifs. Si nous revenons à Ghidra, nous pouvons faire défiler jusqu'en haut pour voir l'adresse de base utilisée :

Ensuite, pour trouver le décalage de notre fonction, nous le soustrayons simplement de l'adresse de notre fonction :
0x14004eea0 - 0x140000000 = 0x4eea0
Cependant, puisque nous voulons réellement faire une pause et inspecter le contenu de RCX avant que la fonction ne soit appelée, je vais simplement récupérer cette adresse à la place :
Qui est au décalage 0x583d4
Pour obtenir l'adresse de base de notre exe dans x64dbg, nous pouvons ouvrir l'onglet de la carte mémoire et voir où il est chargé.

Heureusement, nous n'avons pas réellement besoin de faire de calculs et pouvons simplement l'insérer sous forme de commande dans x64dbg pour visiter ce décalage :

Ensuite, nous pouvons reprendre l'exécution et atteindre ce point d'arrêt
En effet, nous constatons que les instructions démontées correspondent également à celles que nous visionnions dans Ghidra. De là, nous pouvons inspecter le contenu de la mémoire à droite, ce qui nous donne la valeur RCX :
0x000000248D5BFE70
Mais attendez… Cela n’a pas de sens. Nous nous attendons à ce qu'un pointeur de fonction soit dans ce registre, et cette valeur est inférieure à notre décalage de base. Dans ce cas, nous avons fait une pause juste au moment où AVANT RCX est défini. Nous pouvons donc simplement avancer d'une instruction et nous voyons que le contenu de RCX change en quelque chose qui semble plus correct :

0x00007FF718F6A600, qui correspond à notre décalage. Nous pouvons cliquer avec le bouton droit sur cette valeur et la suivre jusqu'à la fenêtre de vidage, dans laquelle nous pouvons cliquer avec le bouton droit sur l'adresse mémoire et copier son décalage de fichier.

Ce qui nous donne : 0x29A00. Dans Ghidra, nous pouvons naviguer vers ce décalage :

Qui est une fonction très sus.

À l’intérieur, nous voyons plein de choses très intéressantes qui indiquent une énumération, comme la ligne suivante :
FUN_1400285d0(&lStack_e0,
*(undefined8 *)((longlong)&PTR_s_%USERPROFILE%\Documents_140388ff0 + lVar11),
*(undefined8 *)((longlong)&DAT_140388ff8 + lVar11));
On dirait qu'il parcourt les données utilisateur, ce qui est logique étant donné le contexte malveillant de ce fichier.
Par souci de digestibilité, cette enquête se poursuivra dans un autre article de blog. Mais pour l'instant, nous avons découvert les mécanismes de base de ce malware