Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
packer-tutorial — Un tutoriel sur la façon d'écrire un packer pour Windows ! | Kitploit
Outils/GitHubGitHub/frank2/packer-tutorial
Rétro-ingénierieAnalyse de MalwareAnalyse de BinairesApprentissage et Éducation
GitHubfrank2/packer-tutorial

packer-tutorial

Un tutoriel sur la façon d'écrire un packer pour Windows !

Voir le dépôt
31932il y a 2 ansVérifié par Kitploit

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager

PACKERS

Table des matières

  1. Qu'est-ce qu'un packer ? : Une introduction au but des packers et un guide sur les nécessités de développement d'un packer.
  2. Prérequis : Outils nécessaires pour travailler avec ce tutoriel.
  3. Un avant-goût : Une démonstration de ce que vous allez construire avec ce tutoriel.
  4. Ébaucher notre projet CMake : Un mini tutoriel CMake sur la création d'un système de build pour les besoins quelque peu complexes d'un packer.
  5. Empaqueter des binaires dans votre stub : Un tutoriel sur la façon d'écrire la partie packer de la combinaison packer/stub, ainsi qu'une introduction au format de fichier exécutable Windows.
    1. Gestion des ressources
    2. Analyse d'un fichier PE
    3. Manipulation d'un fichier PE
  6. Simulation du chargeur : Un tutoriel sur la façon de construire un stub exécutable minimal pour décompresser et charger un exécutable cible, ainsi qu'une introduction à la manipulation plus avancée des exécutables Windows.
    1. Lecture de notre PE depuis la mémoire
    2. Chargement de notre PE pour exécution
    3. Résolution des imports d'API
    4. Résolution des adresses
    5. Transfert de l'exécution
  7. Exercices complémentaires : Quelques exercices pour approfondir vos connaissances en développement de packers.

Une lecture qui semble intimidante ? Essayez la version présentation, qui résume ce readme. Vidéo YouTube bientôt disponible !

Qu'est-ce qu'un packer ?

Un packer est un programme qui décompresse et lance un autre programme dans son espace d'adressage (ou parfois, dans l'espace d'adressage d'un autre processus). Il est parfois connu pour être le vecteur qui attaque les environnements d'analyse, tels que les débogueurs et les sandboxes virtuelles. Il est principalement utilisé pour quelques choses :

  • Compression : Les packers sont couramment employés pour compresser le code d'un binaire donné. C'est l'une de ses rares utilisations légitimes. Voir UPX pour un exemple de packer compressant.
  • Obfuscation : Les packers sont également employés pour tenter d'obfusquer ou de défendre un programme contre la rétro-ingénierie. Voir le packer packman de Riot Games pour un exemple de packer anti-rétro-ingénierie.
  • Évasion : Les malwares utilisent fréquemment une variété de packers afin d'échapper aux antivirus et même aux EDR. Voir cette analyse d'un packer SmokeLoader pour un exemple de packer d'évasion.

Fondamentalement, un packer n'a que quelques étapes de base :

  1. Étape de compression : C'est là que l'exécutable d'origine est compressé, obfusqué, ou les deux, dans un nouveau binaire.
  2. Étape de décompression : C'est là que l'exécutable empaqueté décompresse ou désobfusque son exécutable d'origine pour le chargement.
  3. Étape de chargement : C'est là que le packer imite une variété d'étapes similaires au chargeur d'exécutables de la plateforme hôte. En raison de la complexité des binaires exécutables, c'est l'étape la plus compliquée, selon le niveau de profondeur que vous souhaitez émuler.
  4. Étape d'exécution : C'est là que le code est transféré de l'exécutable stub hôte vers le code nouvellement chargé (c'est-à-dire décompressé).

Tout aussi simple, un packer ne se compose que de quelques éléments :

  • Le packer : Cette partie est responsable de la préparation de ce qu'on appelle l'exécutable stub et de lui fournir les données nécessaires pour décompresser un binaire donné. C'est en fin de compte l'étape de compression.
  • Le stub : Ce binaire est la partie du code responsable de la décompression, du chargement et de l'exécution du binaire d'origine. Comme vous pouvez le voir, l'exécutable stub fait l'essentiel du travail pour un packer.

Construire un packer peut être délicat, car le stub doit être compilé et importé d'une manière ou d'une autre dans l'exécutable du packer. Sous Windows, apprendre le système de build de Visual Studio au-delà de la simple compilation peut être une tâche laborieuse. Heureusement, CMake fournit un système de build simple et multiplateforme qui prend en charge Visual Studio, et il est hautement personnalisable !

Ce tutoriel a pour but d'enseigner les points suivants :

  • Comment segmenter et intégrer les deux éléments principaux d'un packer dans un environnement de build cohérent et testable.
  • Comment manipuler un exécutable Windows pour ajouter des données supplémentaires et chargeables.
  • Comment naviguer dans un exécutable Windows pour récupérer et charger des données arbitraires.
  • Comment émuler des parties du chargeur Windows pour charger et exécuter un exécutable Windows.

Si vous êtes déjà familier avec C++ et CMake, n'hésitez pas à passer directement à la section sur l'empaquetage. Sinon, continuez à lire !

Prérequis

  • Connaissance du C++ : Ce tutoriel est en quelque sorte inutile si vous ne connaissez pas le C++, car nous allons faire beaucoup d'arithmétique de pointeurs.
  • Visual Studio : Visual Studio contient un compilateur C++ Windows complet. Ce projet est testé avec Visual Studio 2019, mais les versions plus récentes devraient convenir.
  • CMake : CMake est le système de build que nous utilisons pour aider à instrumenter nos builds pour le compilateur Visual Studio.

Un avant-goût

Tout d'abord, prouvons, d'une manière ou d'une autre, que ce système de build fonctionne et crée un exécutable correctement empaqueté. Une fois que vous avez installé CMake et Visual Studio, naviguez vers le répertoire racine de ce dépôt avec votre terminal de choix et exécutez la commande suivante :``` $ mkdir build $ cd build $ cmake ../

root@kitploit:~
Cela créera les fichiers de projet nécessaires pour construire le code du tutoriel Packer. Exécutez ensuite :```
$ cmake --build ./ --config Release

Cela compilera le projet packer en mode Release. Ensuite, vous pouvez exécuter le test suivant :``` $ ctest -C Release ./

root@kitploit:~
Si tout se passe bien, test\_pack et test_unpack devraient réussir. Si vous voulez voir les résultats de l'empaquetage vous-même, vous devriez voir ceci :```
$ ./packed.exe
I'm just a little guy!

Parlons de toutes les pièces mobiles qui ont permis cela.

Esquisse de notre projet CMake

Nous savons donc quels sont les principaux composants d'un packer, mais comment pouvons-nous tester notre packer immédiatement dans le cadre du cycle de développement ? Nous devrons ajouter un troisième binaire à notre projet global pour tester correctement le processus de pack et d'unpack de notre binaire. Au total, nous avons besoin de trois projets :

  • le packer
  • le stub
  • l'exécutable factice à packer

Nous avons également besoin d'une bibliothèque de compression pour garantir que le binaire se compresse dans l'exécutable stub. zlib fera parfaitement l'affaire.

Notre hiérarchie de projet, pour commencer, devrait ressembler à ceci :``` packer/ +---+ CMakeLists.txt + dummy/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + stub/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + src/ | | | +---+ main.cpp | + zlib-1.2.13/ | +---+ CMakeLists.txt + ...

root@kitploit:~
Notre main.cpp dans chaque dossier peut simplement ressembler à ceci pour l'instant :```cpp
#include <iostream>

int main(int argc, char *argv[]) {
    std::cout << "I'm just a little guy!" << std::endl;
    
    return 0;
}

Déjà, il convient d'établir que nous avons une simple chaîne de dépendances à gérer, que CMake résoudra proprement pour nous avec un peu d'instrumentation :

  • packer et stub dépendent de zlib
  • packer dépend de stub
  • dummy dépend de packer (car il doit être empaqueté par le packer)

Commençons par le projet racine, le packer, pour notre instrumentation CMake.

CMake exige généralement que l'on déclare une version minimale, car il existe depuis longtemps et prend en charge l'utilisation à long terme des versions antérieures. Ensuite, nous pouvons déclarer notre projet packer en tant que projet C++.```cmake

target a cmake version, you can target a lower version if you like

cmake_minimum_required(VERSION 3.24)

declare our packer as a C++ project (since zlib is a C project and the compilation

detection might get confused)

project(packer CXX)

root@kitploit:~
Nous voulons également déclarer notre packer comme « MultiThreaded » (c.-à-d. /MT) au lieu de « MultiThreadedDLL » (c.-à-d. /MD) afin de ne pas avoir à nous soucier des dépendances DLL d’exécution.```cmake
# this line will mark our packer as MultiThreaded, instead of MultiThreadedDLL
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")

L'instruction entre chevrons dans l'énoncé d'affectation de variable est appelée une expression de générateur et nous aide à résoudre les données relatives à la configuration là où c'est nécessaire, vous la verrez souvent dans ce fichier. Ce que fait cette expression de générateur, c'est émettre la chaîne Debug lorsqu'elle détecte que la configuration est Debug, et ne rien émettre sinon. Cela fournit une bibliothèque d'exécution MultiThreadedDebug lorsqu'un profil de compilation Debug est sélectionné, et MultiThreaded lorsqu'un profil de compilation non-débogué est sélectionné, comme Release. Consultez les expressions de générateur conditionnelles pour bien comprendre ce qui se passe ici. Pour en savoir plus sur la variable CMAKE_MSVC_RUNTIME_LIBRARY, voir la documentation CMake.

CMake vous permet d'organiser votre code source en hiérarchies comme le fait automatiquement Visual Studio lors de la création de projets avec son interface graphique, et il recherchera également récursivement dans vos dossiers les noms de fichiers correspondants. Dans notre fichier de configuration, nous définissons une récursivité globale sur les en-têtes (.hpp), le code (.cpp) et les scripts de ressources (.rc). Pour notre exemple, nous n'avons vraiment besoin que de main.cpp, mais c'est utile à savoir pour les projets plus importants.```cmake

this will collect header, source and resource files into convenient variables

file(GLOB_RECURSE SRC_FILES ${PROJECT_SOURCE_DIR}/src/.cpp) file(GLOB_RECURSE HDR_FILES ${PROJECT_SOURCE_DIR}/src/.hpp) file(GLOB_RECURSE RC_FILES ${PROJECT_SOURCE_DIR}/src/*.rc)

this will give you source groups in the resulting Visual Studio project

source_group(TREE "${PROJECT_SOURCE_DIR}" PREFIX "Header Files" FILES ${HDR_FILES}) source_group(TREE "${PROJECT_SOURCE_DIR}" PREFIX "Source Files" FILES ${SRC_FILES}) source_group(TREE "${PROJECT_SOURCE_DIR}" PREFIX "Resource Files" FILES ${RC_FILES})

root@kitploit:~
Ici, je devrais expliquer que CMake est plus précisément décrit comme un **make system**. Il crée le "make system" approprié pour l'environnement donné en fonction du compilateur fourni. Sur Linux, il s'agirait d'un makefile pour les compilateurs détectés (ou fournis). Pour nous sous Windows, cela produit un projet Visual Studio compatible avec notre version de Visual Studio, ce qui signifie qu'une fois que vous avez créé le make system MSVC, vous pouvez simplement utiliser Visual Studio pour tout si vous le souhaitez ! En fin de compte, vous utilisez CMake pour configurer Visual Studio. Ce que vous faites ici revient essentiellement à créer dans Visual Studio les arborescences des fichiers de votre projet, ce que l'interface graphique fait pour vous lors de la création d'un projet.

Ensuite, nous devons ajouter nos projets dépendants :```cmake
# this will add zlib as a build target
add_subdirectory(${PROJECT_SOURCE_DIR}/zlib-1.2.13)

# this will add our stub project
add_subdirectory(${PROJECT_SOURCE_DIR}/stub)

# this will add our test dummy project
add_subdirectory(${PROJECT_SOURCE_DIR}/dummy)

Une chose que j’ai mentionnée plus tôt est que le projet packer dépend du projet stub. Le packer, d’une manière ou d’une autre, doit conserver le stub et le manipuler pour finalement obtenir notre exécutable packed. Nous pouvons utiliser les fichiers de ressources Windows pour éventuellement intégrer notre exécutable stub dans notre binaire packer, quelle que soit la configuration de build ! En plus de cela, nous pouvons utiliser CMake pour générer ces fichiers pour nous afin que nos références aux exécutables construits soient cohérentes dans le projet CMake ! Générons nos fichiers de ressources pour l’instant afin de pouvoir les inclure dans notre projet :```cmake

this will make sure our stub data will be included in the resources of our packer

despite where it may reside in cmake's build system

file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/$/stub.hpp" CONTENT "#pragma once\n#define IDB_STUB 1000\n") file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/$/stub.rc" CONTENT "#include <winresrc.h>\n#include "stub.hpp"\nIDB_STUB STUB "$<TARGET_FILE:stub>"\n")

root@kitploit:~
`CMAKE_CURRENT_BINARY_DIR` est la chaîne qui contient votre répertoire de build courant. Visual Studio dépose les binaires dans des dossiers en fonction de leur configuration, c'est pourquoi nous utilisons l'instruction génératrice `$<CONFIG>` pour obtenir la configuration actuelle du build. Nous utilisons également l'instruction génératrice `$<TARGET_FILE:stub>` pour générer le nom du fichier exécutable du binaire stub une fois celui-ci compilé. Lorsque nous incluons ces fichiers dans notre projet -- le fichier RC généré et l'en-tête généré -- nous pouvons intégrer avec succès notre binaire stub dans le projet du packer.

Ensuite, nous déclarons un exécutable pour notre projet de packer avec les fichiers récupérés précédemment :```cmake
# this will create our packer executable
add_executable(packer ${HDR_FILES} ${SRC_FILES})
target_sources(packer PRIVATE ${RC_FILES} "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>/stub.rc")

Ensuite, nous lions la bibliothèque zlib importée pour l'éditeur de liens :```cmake

this will link zlib to our packer

target_link_libraries(packer zlibstatic)

root@kitploit:~
Vous pouvez plutôt lier uniquement `zlib` si vous voulez vraiment la DLL de zlib.

Parce que nous avons des fichiers générés (et zlib en fait de même dans le cadre de son processus de construction), nous devons inclure les répertoires dynamiques dans les en-têtes d'inclusion du projet. En raison d'une dépendance à ce que les projets soient à la racine d'un projet dans CMake, nous ajoutons également les répertoires d'inclusion de zlib pour notre binaire stub :```cmake
# zlib, as part of its build step, drops a config header in the build directory.
# we do this too, so make sure to include everything for the build!
target_include_directories(packer PUBLIC
  "${PROJECT_SOURCE_DIR}/src"
  "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>"
  "${PROJECT_SOURCE_DIR}/zlib-1.2.13"
  "${CMAKE_CURRENT_BINARY_DIR}/zlib-1.2.13"
)

# also set the includes for the stub from here.
# we can't set this in the stub CMake file because CMake requires includes to be in the same
# directory as the build target. for this file, our build target is packer, so this sets
# up includes relative to the packer executable.
target_include_directories(stub PUBLIC
  "${PROJECT_SOURCE_DIR}/zlib-1.2.13"
  "${CMAKE_CURRENT_BINARY_DIR}/zlib-1.2.13"
)

Enfin, nous aplanissons les choses dans notre chaîne de dépendances en informant CMake de notre situation de dépendances : marquer le stub comme une dépendance du packer et le packer comme une dépendance de la dummy.```cmake

this will add our stub as a dependency and our dummy as being dependent on the packer.

add_dependencies(packer stub) add_dependencies(dummy packer)

root@kitploit:~
Les fichiers CMake pour [le stub](https://github.com/frank2/packer-tutorial/blob/main/stub/CMakeLists.txt) et [le dummy](https://github.com/frank2/packer-tutorial/blob/main/dummy/CMakeLists.txt) devraient être assez faciles à comprendre maintenant !

Ce qui est génial, c'est que CMake peut gérer les tests pour nous ! Et à ce stade, générer des tests pour notre packer est un processus simple : nous pouvons simplement exécuter une commande, et si le code de sortie est 0, le test réussit. Par souci de simplicité, disons que nous empaquetons un binaire en le passant comme premier argument de l'exécutable. Nous voulons quelque chose comme ceci :```
$ packer.exe dummy.exe

Pour ce faire avec CMake, c'est très simple :```cmake

enable testing to verify our packer works

enable_testing() add_test(NAME test_pack COMMAND "$<TARGET_FILE:packer>" "$<TARGET_FILE:dummy>")

root@kitploit:~
Enfin, tester si le programme se décompresse est encore plus simple : il suffit d'exécuter la sortie ! Il ne devrait pas être surprenant de voir combien d'erreurs vous rencontrerez qui feront planter le programme simplement en l'exécutant lorsque vous tentez d'émuler le chargeur ! Disons encore une fois, par souci de simplicité, que le binaire doit produire "packed.exe" pour un binaire packé. Dans ce cas, tout ce que vous avez à faire est ceci :```cmake
add_test(NAME test_unpack
  COMMAND "packed.exe")

Cela échouera proprement si votre packer n'a pas produit le binaire. Malheureusement, CMake pour Visual Studio ignore la variable ADDITIONAL_CLEAN_FILES, vous devrez donc nettoyer manuellement tous les fichiers générés dans votre système de build, y compris les fichiers stub.rc et stub.hpp générés ci-dessus.

Félicitations ! Beaucoup de travail a été nécessaire pour compiler et tester avec succès votre packer. Maintenant que nous avons mangé nos légumes, notre packer peut faire ce qui suit :

  • compiler toutes les dépendances dans le bon ordre
  • construire le binaire stub et l'injecter dans notre binaire packer
  • tester automatiquement le processus de packing/unpacking

Passons maintenant aux choses intéressantes !

Empaqueter des binaires dans votre stub

Nous avons donc configuré avec succès notre compilateur pour compiler l'exécutable stub comme ressource dans notre binaire packer, mais comment obtenir un binaire dans un état empaqueté dans le stub ? Nous ne pouvons pas l'ajouter comme ressource car nous ne pouvons pas attendre de l'utilisateur final du packer qu'il utilise simplement un compilateur ; l'exécutable packer est censé être une solution autonome.

Une technique que j'ai beaucoup utilisée (même si elle peut être évidente pour l'analyse) consiste à ajouter une nouvelle section au binaire stub pour qu'elle soit finalement chargée à l'exécution. Cela servira également d'introduction accélérée au format PE. Mais d'abord, comment extraire les données des ressources ?

Gérer les ressources

Il y a trois étapes de base pour obtenir les données d'une ressource à partir d'un binaire à l'exécution :

  • Trouver la ressource
  • Charger la ressource
  • Verrouiller la ressource (ce qui permet d'obtenir les octets de la ressource)

La fonction suivante montre comment rechercher et acquérir une ressource à partir d'un binaire donné :```cpp std::vectorstd::uint8_t load_resource(LPCSTR name, LPCSTR type) { auto resource = FindResourceA(nullptr, name, type);

if (resource == nullptr) { std::cerr << "Error: couldn't find resource." << std::endl; ExitProcess(6); }

auto rsrc_size = SizeofResource(GetModuleHandleA(nullptr), resource); auto handle = LoadResource(nullptr, resource);

if (handle == nullptr) { std::cerr << "Error: couldn't load resource." << std::endl; ExitProcess(7); }

auto byte_buffer = reinterpret_cast<std::uint8_t *>(LockResource(handle));

return std::vectorstd::uint8_t(&byte_buffer[0], &byte_buffer[rsrc_size]); }

root@kitploit:~
Avec nos données dans un vecteur facilement malléable, nous pouvons maintenant analyser l'image stub et y ajouter de nouvelles données.

### Analyse d'un fichier PE

Un exécutable Windows, dans ses composants les plus élémentaires, est divisé en deux parties: ses *en-têtes* et ses *données de section*. Les en-têtes contiennent beaucoup de métadonnées importantes pour le processus de chargement, et les données de section ne sont que cela-- des données, qui peuvent être du code exécutable (c'est-à-dire la section `.text`) ou des données arbitraires (c'est-à-dire une section `.data`). Le début de chaque exécutable Windows commence par une structure `IMAGE_DOS_HEADER`:```c
typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
    WORD   e_magic;                     // Magic number
    WORD   e_cblp;                      // Bytes on last page of file
    WORD   e_cp;                        // Pages in file
    WORD   e_crlc;                      // Relocations
    WORD   e_cparhdr;                   // Size of header in paragraphs
    WORD   e_minalloc;                  // Minimum extra paragraphs needed
    WORD   e_maxalloc;                  // Maximum extra paragraphs needed
    WORD   e_ss;                        // Initial (relative) SS value
    WORD   e_sp;                        // Initial SP value
    WORD   e_csum;                      // Checksum
    WORD   e_ip;                        // Initial IP value
    WORD   e_cs;                        // Initial (relative) CS value
    WORD   e_lfarlc;                    // File address of relocation table
    WORD   e_ovno;                      // Overlay number
    WORD   e_res[4];                    // Reserved words
    WORD   e_oemid;                     // OEM identifier (for e_oeminfo)
    WORD   e_oeminfo;                   // OEM information; e_oemid specific
    WORD   e_res2[10];                  // Reserved words
    LONG   e_lfanew;                    // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

Bien que cette structure semble avoir beaucoup de choses à gérer, vous pouvez probablement deviner d'après son nom que cet en-tête est un vestige des anciennes versions de Windows et de Microsoft DOS. Ici, nous ne nous intéressons qu'à deux valeurs de cet en-tête : e_magic et e_lfanew. e_magic est simplement la valeur d'en-tête magique en haut de l'image, le « MZ » au début du fichier. e_lfanew est le décalage vers les en-têtes NT depuis le début du fichier, qui sont les en-têtes PE contenant beaucoup plus d'informations de métadonnées sur l'exécutable. Nous pouvons, par exemple, construire un simple validateur PE pour notre packer comme ceci :```cpp void validate_target(const std::vectorstd::uint8_t &target) { auto dos_header = reinterpret_cast<const IMAGE_DOS_HEADER *>(target.data());

// IMAGE_DOS_SIGNATURE is 0x5A4D (for "MZ") if (dos_header->e_magic != IMAGE_DOS_SIGNATURE) { std::cerr << "Error: target image has no valid DOS header." << std::endl; ExitProcess(3); }

auto nt_header = reinterpret_cast<const IMAGE_NT_HEADERS *>(target.data() + dos_header->e_lfanew);

// IMAGE_NT_SIGNATURE is 0x4550 (for "PE") if (nt_header->Signature != IMAGE_NT_SIGNATURE) { std::cerr << "Error: target image has no valid NT header." << std::endl; ExitProcess(4); }

// IMAGE_NT_OPTIONAL_HDR64_MAGIC is 0x020B if (nt_header->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) { std::cerr << "Error: only 64-bit executables are supported for this example!" << std::endl; ExitProcess(5); } }

root@kitploit:~
`IMAGE_NT_HEADERS` est une structure plutôt volumineuse dans l'ensemble, donc je ne vais pas documenter l'intégralité ici, mais vous pouvez trouver tout ce que vous devez savoir à ce sujet dans [la documentation Microsoft de l'en-tête](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_nt_headers64). De toute façon, nous n'aurons besoin que de quelques membres de structure de ces en-têtes. Pour l'instant, nous devons compresser notre binaire cible pour l'ajouter à nos données de stub.

L'utilisation des fonctions `compress` et `decompress` de zlib est très simple. Si vous voulez vraiment faire des choses plus élaborées avec zlib, je suggère d'utiliser les fonctions `deflate`/`inflate`, qui permettent de travailler sur des flux de compression par morceaux. Voir la section « Advanced Functions » du [manuel zlib](https://www.zlib.net/manual.html). Pour cet exemple cependant, `compress` et `decompress` suffiront.

Pour commencer, nous obtenons une valeur de taille avec la fonction `compressBound` de zlib appliquée à la taille de notre binaire cible. Cette valeur correspond à la taille maximale nécessaire pour contenir un flux de données compressé étant donné la taille des données. Nous pouvons ensuite utiliser cette valeur pour allouer un vecteur contenant les données compressées. La fonction `compress` retourne finalement la taille réelle du tampon compressé, à laquelle nous pouvons redimensionner notre vecteur.```cpp
// get the maximum size of a compressed buffer of the target binary's size.
uLong packed_max = compressBound(target.size());
uLong packed_real = packed_max;

// allocate a vector with that size
std::vector<std::uint8_t> packed(packed_max);
   
if (compress(packed.data(), &packed_real, target.data(), target.size()) != Z_OK)
{
   std::cerr << "Error: zlib failed to compress the buffer." << std::endl;
   ExitProcess(8);
}

// resize the buffer to the real compressed size
packed.resize(packed_real);

Manipulation d'un fichier PE

Prenons un moment pour parler de l'alignement des données. Un flux de données donné est considéré comme aligné si son adresse ou sa taille est divisible par une frontière d'alignement donnée. Par exemple, dans les fichiers PE, les sections de données sur disque sont généralement alignées sur la frontière 0x400, alors qu'en mémoire elles sont alignées sur la frontière 0x1000. Nous pouvons déterminer si une valeur donnée est alignée en effectuant un modulo de l'alignement sur la valeur (c'est-à-dire valeur % alignement == 0). Les fichiers PE peuvent être arbitrairement alignés sur d'autres valeurs, et cette valeur est présente dans le fichier et importante pour le chargeur PE dans son ensemble. Aligner une valeur donnée sur une frontière donnée est une opération relativement simple :```cpp template T align(T value, T alignment) { auto result = value + ((value % alignment == 0) ? 0 : alignment - (value % alignment)); return result; }

root@kitploit:~
Cette fonction complète essentiellement une valeur potentiellement non alignée avec le reste nécessaire pour être correctement alignée sur une limite donnée.

Pour ajouter correctement des données arbitraires à notre fichier PE, nous devons être attentifs en particulier à l'*alignement de fichier*-- nous pourrons calculer les valeurs alignées correctes pour le fichier PE lorsqu'il sera en mémoire plus tard, mais pour l'instant, lors de l'ajout de nos données, nous devons aligner notre fichier sur la limite d'alignement de fichier. Dans le code suivant, nous obtenons les en-têtes, puis les limites d'alignement de fichier et d'alignement de section, puis nous procédons à l'alignement de nos données stub sur la limite du fichier et ajoutons notre section nouvellement empaquetée.```cpp
// next, load the stub and get some initial information
std::vector<std::uint8_t> stub_data = load_resource(MAKEINTRESOURCE(IDB_STUB), "STUB");
auto dos_header = reinterpret_cast<IMAGE_DOS_HEADER *>(stub_data.data());
auto e_lfanew = dos_header->e_lfanew;

// get the nt header and get the alignment information
auto nt_header = reinterpret_cast<IMAGE_NT_HEADERS64 *>(stub_data.data() + e_lfanew);
auto file_alignment = nt_header->OptionalHeader.FileAlignment;
auto section_alignment = nt_header->OptionalHeader.SectionAlignment;

// align the buffer to the file boundary if it isn't already
if (stub_data.size() % file_alignment != 0)
   stub_data.resize(align<std::size_t>(stub_data.size(), file_alignment));
      
// save the offset to our new section for later for our new PE section
auto raw_offset = static_cast<std::uint32_t>(stub_data.size());

// encode the size of our unpacked data into the stub data
auto unpacked_size = target.size();
stub_data.insert(stub_data.end(),
                 reinterpret_cast<std::uint8_t *>(&unpacked_size),
                 reinterpret_cast<std::uint8_t *>(&unpacked_size)+sizeof(std::size_t));

// add our compressed data.
stub_data.insert(stub_data.end(), packed.begin(), packed.end());

Maintenant, nous avons peut-être ajouté les données de notre section conformément aux limites des sections du fichier, mais notre exécutable stub n'est toujours pas conscient de cette section dans le fichier PE. Nous devons non seulement analyser la table des sections du fichier PE, mais aussi ajouter une nouvelle entrée qui pointe vers notre section. C'est la raison d'être de la variable raw_offset.

Tout d'abord, nous pouvons facilement incrémenter le nombre de sections en mettant à jour NumberOfSections. En général, les données après la dernière table de sections sont mises à zéro, nous pouvons donc écraser ces données nulles avec notre nouvelle section sans difficulté.```cpp // increment the number of sections in the file header auto section_index = nt_header->FileHeader.NumberOfSections; ++nt_header->FileHeader.NumberOfSections;

root@kitploit:~
Ensuite, nous devons obtenir un pointeur vers la table des sections elle-même. Bien qu'elle suive techniquement immédiatement l'en-tête optionnel des en-têtes NT, la taille de l'en-tête optionnel est en réalité déterminée par la valeur `SizeOfOptionalHeader` de l'en-tête de fichier NT. Ainsi, pour y parvenir, nous devons calculer un pointeur depuis le début de la structure `OptionalHeader` jusqu'à l'offset fourni par la valeur `SizeOfOptionalHeader`.```cpp
// acquire a pointer to the section table
auto size_of_header = nt_header->FileHeader.SizeOfOptionalHeader;
auto section_table = reinterpret_cast<IMAGE_SECTION_HEADER *>(
   reinterpret_cast<std::uint8_t *>(&nt_header->OptionalHeader)+size_of_header
);

Enfin, nous sommes prêts à commencer à ajouter nos métadonnées de section. Voici notre en-tête de section PE :```c typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // IMAGE_SIZEOF_SHORT_NAME is 8 union { DWORD PhysicalAddress; DWORD VirtualSize; } Misc; DWORD VirtualAddress; DWORD SizeOfRawData; DWORD PointerToRawData; DWORD PointerToRelocations; DWORD PointerToLinenumbers; WORD NumberOfRelocations; WORD NumberOfLinenumbers; DWORD Characteristics; } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

root@kitploit:~
Les variables qui nous intéressent particulièrement pour notre nouvel en-tête de section sont `Name`, `VirtualSize`, `VirtualAddress`, `SizeOfRawData`, `PointerToRawData` et `Characteristics`. À ce stade, vous devez savoir que la raison pour laquelle vous devez être conscient de deux types d'alignement-- l'alignement de fichier et l'alignement mémoire-- est qu'il existe deux états mémoire différents pour un exécutable PE donné : ce à quoi il ressemble sur *disque*, et ce à quoi il ressemble en *mémoire*, en raison du processus de chargement. Il est possible de configurer un fichier PE donné pour qu'il ait la même disposition mémoire, qu'il soit chargé ou non, mais ce n'est pas une configuration fréquente.

La variable `Name` est l'étiquette de 8 octets que vous pouvez donner à votre nouvelle section. J'ai choisi `.packed`, car c'est une chaîne ASCII de 7 octets qui tient parfaitement dans le tampon.

`VirtualAddress` fait référence au décalage d'une section donnée en mémoire. On l'appelle également « adresse virtuelle relative », ou RVA. `VirtualSize` fait référence à la taille de la section en mémoire. (À noter, MSVC compile cette valeur comme étant la valeur de taille non alignée de la section, nous suivons donc également cette convention dans notre section.) `PointerToRawData` fait référence au décalage d'une section donnée sur le disque, et `SizeOfRawData` fait référence à la taille de la section sur le disque.

`Characteristics` est complexe et peut indiquer qu'une section est lisible, inscriptible ou exécutable, en plus d'autres indicateurs, voir la section characteristics de [la documentation `IMAGE_SECTION_HEADER`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_section_header). Pour l'instant, sachez que tout ce dont nous avons besoin est que la section soit lisible et marquée comme contenant des données initialisées.

Avec tout cela à l'esprit, nous pouvons maintenant créer notre nouvelle section !```cpp
// get a pointer to our new section and the previous section
auto section = &section_table[section_index];
auto prev_section = &section_table[section_index-1];

// calculate the memory offset, memory size and raw aligned size of our packed section
auto virtual_offset = align(prev_section->VirtualAddress + prev_section->Misc.VirtualSize, section_alignment);
auto virtual_size = section_size;
auto raw_size = align<DWORD>(section_size, file_alignment);

// assign the section metadata
std::memcpy(section->Name, ".packed", 8);
section->Misc.VirtualSize = virtual_size;
section->VirtualAddress = virtual_offset;
section->SizeOfRawData = raw_size;
section->PointerToRawData = raw_offset;

// mark our section as initialized, readable data.
section->Characteristics = IMAGE_SCN_MEM_READ | IMAGE_SCN_CNT_INITIALIZED_DATA;

Ceci pose désormais un petit problème : la taille de l'image a changé. On pourrait penser que ce n'est pas un problème, mais dans l'en-tête optionnel de l'en-tête NT se trouve une variable nommée SizeOfImage qui détermine l'espace que le chargeur doit allouer pour notre exécutable. C'est toutefois une correction simple : la taille nécessaire pour notre image est la taille de la dernière section alignée sur la limite d'alignement des sections.```cpp // calculate the new size of the image. nt_header->OptionalHeader.SizeOfImage = align(virtual_offset + virtual_size, section_alignment);

root@kitploit:~
Et voilà ! Nous avons réussi à ajouter notre binaire compressé comme nouvelle section pour que notre stub puisse éventuellement le décompresser et le charger. Maintenant, nous pouvons simplement enregistrer l'image du stub modifié sur le disque.```cpp
std::ofstream fp("packed.exe", std::ios::binary);
   
if (!fp.is_open()) {
   std::cerr << "Error: couldn't open packed binary for writing." << std::endl;
   ExitProcess(9);
}
   
fp.write(reinterpret_cast<const char *>(stub_data.data()), stub_data.size());
fp.close();

Félicitations ! Nous avons jusqu'à présent accompli ce qui suit :

  • Compilé, injecté et récupéré notre binaire stub depuis le répertoire de ressources de notre packer au moment de l'exécution
  • Analysé les en-têtes exécutables de notre binaire stub et de notre binaire cible pour récupérer des informations clés
  • Modifié un exécutable pour étendre les données de sa section afin de contenir notre exécutable empaqueté

Nous avons terminé la moitié de l'écriture de notre packer ! Nous pouvons maintenant passer à ce qui est sans doute la partie la plus difficile du processus : étoffer le binaire stub.

Simulation du chargeur

Bien que les subtilités de l'écriture d'un stub de dépaquetage puissent se compliquer sous le capot, cela se résume fondamentalement à quelques étapes :

  • Récupérer les données de l'image : acquérir et décompresser (et éventuellement désobfusquer) la représentation binaire cible pour un traitement ultérieur.
  • Charger l'image : de loin la plus compliquée, c'est ici que le chargeur est simulé et que l'image cible est préparée pour l'exécution.
  • Appeler le point d'entrée de l'image : généralement appelé "original entry point", ou OEP, c'est le point à partir duquel vous passez de l'étape de chargement à l'étape d'exécution de votre binaire empaqueté.

Ce processus est si simple que notre routine principale n'est qu'une poignée de fonctions :```cpp int main(int argc, char *argv[]) { // first, decompress the image from our added section auto image = get_image();

// next, prepare the image to be a virtual image
auto loaded_image = load_image(image);

// resolve the imports from the executable load_imports(loaded_image);

// relocate the executable relocate(loaded_image);

// get the headers from our loaded image auto nt_headers = get_nt_headers(loaded_image);

// acquire and call the entrypoint auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint; reinterpret_cast<void(*)()>(entrypoint)();

return 0; }

root@kitploit:~
### Lecture de notre PE depuis la mémoire

D'abord, nous devons d'une manière ou d'une autre obtenir les données de notre section créée par le packer depuis le binaire en cours d'exécution. Est-il possible d'obtenir les en-têtes du binaire en cours d'exécution à l'exécution ? Oui, absolument ! [`GetModuleHandleA`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlea), avec un argument nul, renvoie finalement un pointeur vers les en-têtes de notre PE en cours d'exécution ! Ainsi, à l'exécution, vous avez un accès facile à l'image telle qu'elle existe en mémoire. C'est ce qui rend l'ajout d'une nouvelle section à notre binaire si attrayant : nous pouvons très facilement analyser notre section cible depuis le binaire.

Avec ce que nous avons appris sur l'analyse de la table des sections, cette section de code devrait être facile à comprendre :```cpp
// find our packed section
auto base = reinterpret_cast<const std::uint8_t *>(GetModuleHandleA(NULL));
auto nt_header = get_nt_headers(base);
auto section_table = reinterpret_cast<const IMAGE_SECTION_HEADER *>(
   reinterpret_cast<const std::uint8_t *>(&nt_header->OptionalHeader)+nt_header->FileHeader.SizeOfOptionalHeader
);
const IMAGE_SECTION_HEADER *packed_section = nullptr;

for (std::uint16_t i=0; i<nt_header->FileHeader.NumberOfSections; ++i)
{
   if (std::memcmp(section_table[i].Name, ".packed", 8) == 0)
   {
      packed_section = &section_table[i];
      break;
   }
}

if (packed_section == nullptr) {
   std::cerr << "Error: couldn't find packed section in binary." << std::endl;
   ExitProcess(1);
}

Ensuite, nous devons décompresser nos données du stub depuis le binaire. zlib recommande que la taille de la charge utile décompressée d'origine soit accessible d'une certaine manière à la routine de décompression, c'est pourquoi nous avons encodé la taille du binaire décompressé dans l'en-tête de nos données compressées. Ainsi, nous obtenons un pointeur vers nos données décompressées, créons un nouveau tampon pouvant contenir les données décompressées, puis procédons à l'appel de la fonction de décompression de zlib.```cpp // decompress our packed image auto section_start = base + packed_section->VirtualAddress; auto section_end = section_start + packed_section->Misc.VirtualSize; auto unpacked_size = *reinterpret_cast<const std::size_t *>(section_start); auto packed_data = section_start + sizeof(std::size_t); auto packed_size = packed_section->Misc.VirtualSize - sizeof(std::size_t);

auto decompressed = std::vectorstd::uint8_t(unpacked_size); uLong decompressed_size = static_cast(unpacked_size);

if (uncompress(decompressed.data(), &decompressed_size, packed_data, packed_size) != Z_OK) { std::cerr << "Error: couldn't decompress image data." << std::endl; ExitProcess(2); }

return decompressed;

root@kitploit:~
Comme vous pouvez le voir, `get_image` était une fonction relativement simple en fin de compte. Nous avons obtenu l'extraction de notre binaire cible depuis la section ajoutée, il nous faut donc maintenant le charger.

### Chargement de notre PE pour l'exécution

Le chargeur d'exécutables Windows fait beaucoup de choses différentes sous le capot et prend en charge de nombreuses configurations d'exécutables différentes. Vous rencontrerez probablement diverses erreurs avec le packer que ce tutoriel produit si vous explorez au-delà du binaire d'exemple, car la configuration que nous allons construire aujourd'hui est techniquement très minimale. Mais pour établir ce minimum indispensable à l'exécution, nous devons faire les choses suivantes pour charger un exécutable Windows moderne :

* allouer l'image qui contient la représentation mémoire de l'image exécutable
* mapper les sections de notre image exécutable, y compris les en-têtes, vers cette image allouée
* résoudre les imports au moment de l'exécution vers les autres bibliothèques dont notre binaire a besoin
* remapper l'image binaire afin qu'une variété d'adresses dans notre image pointent là où elles le devraient

Commençons par la fonction `load_image`. Pour commencer, nous devons obtenir notre table de sections à partir du binaire packé. Celle-ci sera éventuellement mappée sur notre image fraîchement allouée. Pour un tampon exécutable approprié, l'allocation est très simple : prenez la valeur `SizeOfImage` de nos en-têtes de sections et créez un nouveau tampon avec [`VirtualAlloc`](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualalloc) qui crée une image lisible, inscriptible et exécutable sur laquelle nous pouvons mapper.```cpp
// get the original image section table
auto nt_header = get_nt_headers(image.data());
auto section_table = reinterpret_cast<const IMAGE_SECTION_HEADER *>(
   reinterpret_cast<const std::uint8_t *>(&nt_header->OptionalHeader)+nt_header->FileHeader.SizeOfOptionalHeader
);

// create a new VirtualAlloc'd buffer with read, write and execute privileges
// that will fit our image
auto image_size = nt_header->OptionalHeader.SizeOfImage;
auto base = reinterpret_cast<std::uint8_t *>(VirtualAlloc(nullptr,
                                                          image_size,
                                                          MEM_COMMIT | MEM_RESERVE,
                                                          PAGE_EXECUTE_READWRITE));

if (base == nullptr) {
   std::cerr << "Error: VirtualAlloc failed: Windows error " << GetLastError() << std::endl;
   ExitProcess(3);
}

Avec notre tampon alloué, ce que nous devons faire, c'est copier les en-têtes et les sections dans celui-ci. Cela doit être conforme à la variable SectionAlignment vue précédemment. Heureusement, la manière dont nous avons préparé notre section-- et la manière dont les autres sections sont préparées-- signifie qu'elles sont déjà alignées sur la limite de SectionAlignment. Bref, le résultat final est une simple arithmétique de pointeurs : copier notre image cible au décalage PointerToRawData, dans notre image chargée au décalage VirtualAddress.

En option, vous pouvez copier les en-têtes PE en haut de l'image. Conserver les en-têtes d'origine facilite le développement, mais vouloir les supprimer est un bon pas vers la création d'un packer hostile à l'analyse. Nous copions ici les en-têtes par souci de simplicité.```cpp // copy the headers to our new virtually allocated image std::memcpy(base, image.data(), nt_header->OptionalHeader.SizeOfHeaders);

// copy our sections to their given addresses in the virtual image for (std::uint16_t i=0; i<nt_header->FileHeader.NumberOfSections; ++i) if (section_table[i].SizeOfRawData > 0) std::memcpy(base+section_table[i].VirtualAddress, image.data()+section_table[i].PointerToRawData, section_table[i].SizeOfRawData);

return base;

root@kitploit:~
La partie facile du processus de chargement est donc terminée : nous avons récupéré notre binaire depuis la mémoire, l'avons dépaqueté, et remappé ses sections vers une région mémoire exécutable. Avec notre image préparée, nous pouvons maintenant plonger dans les détails concrets du processus de chargement.

### Résolution des imports d'API

Dans les en-têtes optionnels se trouve ce qu'on appelle un *répertoire de données*. Ce répertoire contient de nombreuses informations différentes sur l'exécutable, telles que les symboles exportés par l'image et les ressources comme les icônes et les bitmaps. Dans ce tutoriel, nous allons analyser deux répertoires de données : le **répertoire d'imports** et le **répertoire de relocalisation**. Chaque répertoire est codé en dur, et leurs index se trouvent [dans la documentation de l'en-tête optionnel](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header32) (faites défiler jusqu'à la description de `DataDirectory`). Un répertoire de données est présent si sa valeur `VirtualAddress` est non nulle.```c
typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;
    DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

Nous obtenons des pointeurs vers les répertoires de données en convertissant le RVA fourni. Par exemple, voici comment vous accédez finalement à la table d'importation à partir du répertoire de données d'importation :```cpp // get the import table directory entry auto nt_header = get_nt_headers(image); auto directory_entry = nt_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];

// if there are no imports, that's fine-- return because there's nothing to do. if (directory_entry.VirtualAddress == 0) { return; }

// get a pointer to the import descriptor array auto import_table = reinterpret_cast<IMAGE_IMPORT_DESCRIPTOR *>(image + directory_entry.VirtualAddress);

root@kitploit:~
Pour résoudre les importations d'API, le chargeur analyse ce répertoire, puis charge les bibliothèques nécessaires et acquiert leurs fonctions importées. Il s'agit heureusement d'un répertoire relativement facile à analyser.

Il commence par une structure de descripteur d'importation :```c
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;            // 0 for terminating null import descriptor
        DWORD   OriginalFirstThunk;         // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;                  // 0 if not bound,
                                            // -1 if bound, and real date\time stamp
                                            //     in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
                                            // O.W. date/time stamp of DLL bound to (Old BIND)

    DWORD   ForwarderChain;                 // -1 if no forwarders
    DWORD   Name;
    DWORD   FirstThunk;                     // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;

Ce qui nous préoccupe le plus, ce sont deux variables dont les noms prêtent un peu à confusion : OriginalFirstThunk et FirstThunk. OriginalFirstThunk contient des informations sur les importations souhaitées par cet exécutable en ce qui concerne la DLL donnée par le RVA Name. Pour ajouter à la confusion, FirstThunk fait de même. Qu'est-ce qui les différencie ? FirstThunk contient les importations après leur résolution. Cette résolution est effectuée par la fameuse fonction GetProcAddress.

Nos thunks sont des structures de données supplémentaires à prendre en compte :```c typedef struct _IMAGE_THUNK_DATA64 { union { ULONGLONG ForwarderString; // PBYTE ULONGLONG Function; // PDWORD ULONGLONG Ordinal; ULONGLONG AddressOfData; // PIMAGE_IMPORT_BY_NAME } u1; } IMAGE_THUNK_DATA64;

root@kitploit:~
Cette structure de données couvre les thunks d'importation ainsi que d'exportation, donc `ForwarderString` peut être ignoré. Un thunk d'importation peut être soit un `Ordinal`, soit une RVA vers une autre structure, `IMAGE_IMPORT_BY_NAME`. Un *ordinal* est simplement un décalage dans la table d'exportation de la DLL donnée. Une structure `IMAGE_IMPORT_BY_NAME` ressemble à ceci :```c
typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;
    CHAR   Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

Ceci est un exemple de structure variadique. Elle tire parti de l'absence de vérification des bornes dans l'accès aux tableaux afin de créer des structures dont la taille peut varier. Name, dans ce cas, est censée être une chaîne C terminée par un zéro.

Parce que les données binaires ne contiennent pas de types, un ordinal et un import sont différenciés par le bit de poids fort dans l'entrée de thunk. L'ordinal est contenu dans la moitié inférieure de l'entier, aussi bien sur les implémentations 32 bits que 64 bits.

Notre table d'imports fonctionne comme une chaîne C-- sa dernière entrée est un OriginalFirstThunk terminé par null pour signifier la fin des imports potentiels. Nos données de thunk fonctionnent de la même manière, se terminant par une entrée nulle dans le tableau de thunks.

Pour tout assembler, voici comment nous abordons l'analyse de la table d'imports en pseudocode :``` for every import descriptor: load the dll parse the original and first thunk

root@kitploit:~
for every thunk:
    if ordinal bit set:
        import by ordinal
    else:
        import by name
        
    store import in first thunk
root@kitploit:~
Fait intéressant, bien que `GetProcAddress` soit typée avec une chaîne C comme argument de la fonction, Windows s’attend à ce que vous importiez par ordinal en castant simplement la valeur ordinale comme une chaîne C. Allez savoir.

Grâce à ces explications, cette boucle while devrait maintenant avoir du sens :```cpp
// when we reach an OriginalFirstThunk value that is zero, that marks the end of our array.
// typically all values in the import descriptor are zero, but we do this
// to be shorter about it.
while (import_table->OriginalFirstThunk != 0)
{
   // get a string pointer to the DLL to load.
   auto dll_name = reinterpret_cast<char *>(image + import_table->Name);

   // load the DLL with our import.
   auto dll_import = LoadLibraryA(dll_name);

   if (dll_import == nullptr) {
      std::cerr << "Error: failed to load DLL from import table: " << dll_name << std::endl;
      ExitProcess(4);
   }

   // load the array which contains our import entries
   auto lookup_table = reinterpret_cast<IMAGE_THUNK_DATA64 *>(image + import_table->OriginalFirstThunk);

   // load the array which will contain our resolved imports
   auto address_table = reinterpret_cast<IMAGE_THUNK_DATA64 *>(image + import_table->FirstThunk);

   // an import can be one of two things: an "import by name," or an "import ordinal," which is
   // an index into the export table of a given DLL.
   while (lookup_table->u1.AddressOfData != 0)
   {
      FARPROC function = nullptr;
      auto lookup_address = lookup_table->u1.AddressOfData;

      // if the top-most bit is set, this is a function ordinal.
      // otherwise, it's an import by name.
      if (lookup_address & IMAGE_ORDINAL_FLAG64 != 0)
      {
         // get the function ordinal by masking the lower 32-bits of the lookup address.
         function = GetProcAddress(dll_import,
                                   reinterpret_cast<LPSTR>(lookup_address & 0xFFFFFFFF));

         if (function == nullptr) {
            std::cerr << "Error: failed ordinal lookup for " << dll_name << ": " << (lookup_address & 0xFFFFFFFF) << std::endl;
            ExitProcess(5);
         }
      }
      else {
         // in an import by name, the lookup address is an offset to
         // an IMAGE_IMPORT_BY_NAME structure, which contains our function name
         // to import
         auto import_name = reinterpret_cast<IMAGE_IMPORT_BY_NAME *>(image + lookup_address);
         function = GetProcAddress(dll_import, import_name->Name);

         if (function == nullptr) {
            std::cerr << "Error: failed named lookup: " << dll_name << "!" << import_name->Name << std::endl;
            ExitProcess(6);
         }
      }

      // store either the ordinal function or named function
      // in our address table.
      address_table->u1.Function = reinterpret_cast<std::uint64_t>(function);

      // advance to the next entries in the address table and lookup table
      ++lookup_table;
      ++address_table;
   }

   // advance to the next entry in our import table
   ++import_table;
}

Une fois les imports résolus, nous pouvons maintenant passer à la dernière partie : le traitement du répertoire de relocalisation !

Résolution des adresses

Le prochain répertoire de données à traiter est ce qu'on appelle le répertoire de relocalisation. Ce répertoire est chargé de traduire les adresses absolues du code en leurs nouvelles valeurs de base. Ce processus implémente essentiellement ce que vous connaissez peut-être sous le nom de randomisation de l'agencement de l'espace d'adressage, mais il est également chargé de veiller à ce que les espaces d'adressage des DLL n'entrent pas en collision les uns avec les autres.

Tout d'abord, nous devons nous assurer que notre binaire est réellement capable de déplacer les bases d'adresses. Parfois, en particulier pour les anciens binaires, cette fonctionnalité n'est pas activée. Parmi les caractéristiques d'un exécutable Windows donné se trouvent ses caractéristiques, curieusement appelées DllCharacteristics dans l'en-tête optionnel. Ce qui nous intéresse, c'est la caractéristique "base dynamique". Il existe une manière spéciale de dépaqueter les binaires non-ASLR, que nous n'abordons pas, donc c'est une erreur si notre binaire ne la prend pas en charge. (C'est mieux en tant qu'erreur dans votre packer, et non dans votre stub, mais pour la fluidité de l'apprentissage de l'en-tête PE, cela a été placé ici à la place.)```cpp // first, check if we can even relocate the image. if the dynamic base flag isn't set, // then this image probably isn't prepared for relocating. auto nt_header = get_nt_headers(image);

if (nt_header->OptionalHeader.DllCharacteristics & IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE == 0) { std::cerr << "Error: image cannot be relocated." << std::endl; ExitProcess(7); }

// once we know we can relocate the image, make sure a relocation directory is present auto directory_entry = nt_header->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];

if (directory_entry.VirtualAddress == 0) { std::cerr << "Error: image can be relocated, but contains no relocation directory." << std::endl; ExitProcess(8); }

root@kitploit:~
Ensuite, nous devons calculer le *delta d'adresse*. Il s'agit simplement de la différence entre la variable `ImageBase` de l'image et l'adresse de base de l'image virtuelle. Il est utilisé pour ajuster rapidement les valeurs des adresses codées en dur dans le binaire.```cpp
// calculate the difference between the image base in the compiled image
// and the current virtually allocated image. this will be added to our
// relocations later.
std::uintptr_t delta = reinterpret_cast<std::uintptr_t>(image) - nt_header->OptionalHeader.ImageBase;

Maintenant, nous sommes prêts à nous attaquer à la table de relocalisation.```c typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; DWORD SizeOfBlock; // WORD TypeOffset[1]; } IMAGE_BASE_RELOCATION;

root@kitploit:~
Notez que le tableau `TypeOffset` commenté est en réalité pertinent ici ! Une table de relocalisation se compose de blocs d'offsets contenant des adresses à ajuster, identifiés par le RVA `VirtualAddress`. Le tableau `TypeOffset` contient des valeurs de mots encodées qui renferment le type de relocalisation ainsi que l'offset à ajuster depuis `VirtualAddress`. En ce qui concerne le type de relocalisation, pour les binaires 64 bits, un seul type nous intéresse. Malheureusement, cette structure n'est pas réellement une structure variadique, nous devons donc faire un peu d'arithmétique de pointeurs pour obtenir le tableau `TypeOffset`.

Comme mentionné, le tableau `TypeOffset` contient des mots encodés. Les 4 bits de poids fort (masque `0xF000`) contiennent le type de relocalisation, que l'on peut trouver dans la ["base relocation types" section](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) de la documentation du format PE. Les 12 bits de poids faible (masque `0x0FFF`) contiennent l'offset à ajuster par rapport à l'argument `VirtualAddress`.

Expliquer cela est fastidieux et, au final, cela s'avère très déroutant. Voici à quoi ressemble l'obtention d'un pointeur vers une adresse à relocaliser :```cpp
auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);

Et voici à quoi ressemble l'ajustement de cette adresse :```cpp *ptr += delta;

root@kitploit:~
Aussi compliquée que soit l'explication du répertoire de relocalisation, les opérations sont en réalité très simples à comprendre en code.

Passer au bloc suivant des données de relocalisation est facilité par la variable `SizeOfBlock`. Ce bloc contient la taille de notre en-tête *ainsi que* la taille du tableau `TypeOffset`. Si `TypeOffset` était plutôt un tableau de taille statique, nous passerions simplement à l'entrée de relocalisation suivante en ajoutant l'appel à `sizeof` de l'en-tête de relocalisation.

Maintenant que tout cela est expliqué, vous devriez être en mesure de comprendre ce code de relocalisation :```cpp
// get the relocation table.
auto relocation_table = reinterpret_cast<IMAGE_BASE_RELOCATION *>(image + directory_entry.VirtualAddress);

// when the virtual address for our relocation header is null,
// we've reached the end of the relocation table.
while (relocation_table->VirtualAddress != 0)
{
   // since the SizeOfBlock value also contains the size of the relocation table header,
   // we can calculate the size of the relocation array by subtracting the size of
   // the header from the SizeOfBlock value and dividing it by its base type: a 16-bit integer.
   std::size_t relocations = (relocation_table->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(std::uint16_t);

   // additionally, the relocation array for this table entry is directly after
   // the relocation header
   auto relocation_data = reinterpret_cast<std::uint16_t *>(&relocation_table[1]);

   for (std::size_t i=0; i<relocations; ++i)
   {
      // a relocation is an encoded 16-bit value:
      //   * the upper 4 bits are its relocation type
      //     (https://learn.microsoft.com/en-us/windows/win32/debug/pe-format see "base relocation types")
      //   * the lower 12 bits contain the offset into the relocation entry's address base into the image
      //
      auto relocation = relocation_data[i];
      std::uint16_t type = relocation >> 12;
      std::uint16_t offset = relocation & 0xFFF;
      auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);

      // there are typically only two types of relocations for a 64-bit binary:
      //   * IMAGE_REL_BASED_DIR64: a 64-bit delta calculation
      //   * IMAGE_REL_BASED_ABSOLUTE: a no-op
      //
      if (type == IMAGE_REL_BASED_DIR64)
         *ptr += delta;
   }

   // the next relocation entry is at SizeOfBlock bytes after the current entry
   relocation_table = reinterpret_cast<IMAGE_BASE_RELOCATION *>(
      reinterpret_cast<std::uint8_t *>(relocation_table) + relocation_table->SizeOfBlock
   );
}

Félicitations ! Notre image est maintenant prête pour l'exécution ! Nous avons accompli beaucoup de choses jusqu'ici :

  • nous avons dépaqueté notre binaire depuis notre table de sections au moment de l'exécution
  • nous avons mappé notre binaire sur une région mémoire exécutable
  • nous avons résolu les imports nécessaires à l'exécution de notre binaire
  • nous avons relocalisé les adresses de l'image pour pointer vers notre nouvelle base d'image

Nous sommes maintenant prêts à exécuter notre binaire dépaqueté !

Transfert de l'exécution

Revenons maintenant à notre boucle principale :```cpp int main(int argc, char *argv[]) { // first, decompress the image from our added section auto image = get_image();

// next, prepare the image to be a virtual image
auto loaded_image = load_image(image);

// resolve the imports from the executable load_imports(loaded_image);

// relocate the executable relocate(loaded_image);

// get the headers from our loaded image auto nt_headers = get_nt_headers(loaded_image);

// acquire and call the entrypoint auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint; reinterpret_cast<void(*)()>(entrypoint)();

return 0; }

root@kitploit:~
Comme vous pouvez le voir, transférer l'exécution est très simple, bien que cela nécessite la connaissance des [pointeurs de fonction](https://en.wikipedia.org/wiki/Function_pointer). Voici nos éléments pertinents :```cpp
// acquire and call the entrypoint
auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint;
reinterpret_cast<void(*)()>(entrypoint)();

AddressOfEntryPoint est, comme vous pouvez le deviner, une RVA dans l'entrée de code de notre image chargée. Malgré ce que vous savez peut-être sur les points d'entrée main, le point d'entrée brut d'un binaire donné n'est pas typé-- votre compilateur C++ est principalement responsable de la mise en place de l'environnement de code pour transmettre les arguments à vos fonctions main attendues, qu'il s'agisse de main ou de WinMain.

Fondamentalement, voici comment un pointeur de fonction est déclaré :```c return_type (*variable_name)(int arg1, int arg2, ...)

root@kitploit:~
Par conséquent, notre pointeur de fonction pour appeler notre point d'entrée-- sans type associé-- ressemble à ceci :```c
void (*entrypoint)()

En tant que cast, cela se réduit à ceci :```c void(*)()

root@kitploit:~
En rassemblant tout, nous pouvons simplement convertir notre point d'entrée en pointeur de fonction et l'appeler sur la même ligne, comme ceci :```cpp
reinterpret_cast<void(*)()>(entrypoint)();

Avec tout chargé correctement, vous devriez voir votre programme empaqueté s'exécuter. Dans notre cas, puisque nous avons empaqueté notre exécutable factice, il affiche simplement un message :``` $ ./packed.exe I'm just a little guy!

root@kitploit:~
Félicitations ! Vous avez terminé ! Vous venez d'écrire un packer Windows !

## Exercices complémentaires

* **Attaquer l'analyste** : Apprenez à implémenter [quelques techniques anti-débogage](https://anti-reversing.com/Downloads/Anti-Reversing/The_Ultimate_Anti-Reversing_Reference.pdf) et durcissez votre packer.
* **Élargissez votre prise en charge** : Apprenez à implémenter un binaire non relocalisable, ou apprenez à implémenter d'autres répertoires tels que le répertoire de stockage local des threads (`IMAGE_TLS_DIRECTORY`) et le répertoire des ressources (`IMAGE_RESOURCE_DIRECTORY`). La documentation faisant défaut à ce niveau, vous pouvez consulter mes implémentations dans [exe-rs](https://github.com/exe-rs).
* **Obfusquez votre stub** : Essayez de comprendre comment l'analyste va dépaqueter votre binaire et empêchez qu'il soit dépaqueté facilement, par exemple en [effaçant les en-têtes](https://github.com/frank2/packer-tutorial/blob/main/stub/src/main.cpp#L84).
Télécharger l’outil