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

Une lecture qui semble intimidante ? Essayez la version présentation, qui résume ce readme. Vidéo YouTube bientôt disponible !
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 :
Fondamentalement, un packer n'a que quelques étapes de base :
Tout aussi simple, un packer ne se compose que de quelques éléments :
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 :
Si vous êtes déjà familier avec C++ et CMake, n'hésitez pas à passer directement à la section sur l'empaquetage. Sinon, continuez à lire !
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 ../
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 ./
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.
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 :
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 + ...
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 :
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
cmake_minimum_required(VERSION 3.24)
project(packer CXX)
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
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)
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})
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
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")
`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
target_link_libraries(packer zlibstatic)
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
add_dependencies(packer stub) add_dependencies(dummy packer)
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() add_test(NAME test_pack COMMAND "$<TARGET_FILE:packer>" "$<TARGET_FILE:dummy>")
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 :
Passons maintenant aux choses intéressantes !
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 ?
Il y a trois étapes de base pour obtenir les données d'une ressource à partir d'un binaire à l'exécution :
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]); }
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); } }
`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);
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;
}
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;
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;
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 = §ion_table[section_index];
auto prev_section = §ion_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);
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 :
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.
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 :
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; }
### 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 = §ion_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;
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;
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);
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;
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
for every thunk:
if ordinal bit set:
import by ordinal
else:
import by name
store import in first thunk
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 !
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); }
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;
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;
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 sommes maintenant prêts à exécuter notre binaire dépaqueté !
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; }
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, ...)
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(*)()
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!
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).