Gant de sécurité offensif 2025 - L'Ombre des voleurs

· 9min · Juicecat

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- index.md-1.png dans le répertoire a.smith, nous obtenons le dossier personnel de l'utilisateur index.md-3.png

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

index.md-2.png

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, Stealers-shadow-1.png Le premier n’a rien d’intéressant, juste un passe-partout Stealers-shadow-2.png

La suivante est une fonction d'injection utilisée pour préparer les données Stealers-shadow-3.png

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 : Stealers-shadow-4.png

      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.

Stealers-shadow-5.png 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 Stealers-shadow-6.png 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 : Stealers-shadow-8.png 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. Stealers-shadow-9.png

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.

Stealers-shadow-10.png

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. Stealers-shadow-11.png Entrons dans cette fonction et renommez-la en main pendant que nous y sommes. Stealers-shadow-12.png

Excellent, un autre emballage ! Allons-y et suivons FUN_14004eea0 Stealers-shadow-13.png 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- Stealers-shadow-14.png

Et en effet, la couche wrapper précédente préparait ce registre à être lu : Stealers-shadow-15.png

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. Stealers-shadow-17.png

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).

Stealers-shadow-16.png

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 : Stealers-shadow-19.png

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 : Stealers-shadow-25.png 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é. Stealers-shadow-24.png

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 : Stealers-shadow-26.png

Ensuite, nous pouvons reprendre l'exécution et atteindre ce point d'arrêt Stealers-shadow-27.png 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 : Stealers-shadow-28.png

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. Stealers-shadow-30.png

Ce qui nous donne : 0x29A00. Dans Ghidra, nous pouvons naviguer vers ce décalage : Stealers-shadow-31.png

Qui est une fonction très sus. Stealers-shadow-32.png

À 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