
¡Un tutorial sobre cómo escribir un packer para Windows!

¿Parece una lectura desalentadora? Prueba la versión en presentación, que resume este readme. ¡Pronto habrá un video de YouTube!
Un packer es un programa que descomprime y lanza otro programa dentro de su espacio de direcciones (o, a veces, el espacio de direcciones de otro proceso). A veces se le conoce por ser el vector que ataca entornos de análisis, como depuradores y sandboxes virtuales. Se utiliza principalmente para algunas cosas:
Fundamentalmente, un packer solo tiene unos pocos pasos básicos:
De manera igualmente simple, un packer solo consta de unas pocas piezas:
Construir un packer puede ser complicado, ya que el stub debe compilarse e importarse de algún modo en el ejecutable del packer. En Windows, aprender el sistema de compilación de Visual Studio más allá de la simple compilación puede ser una tarea laboriosa. Afortunadamente, CMake proporciona un sistema de compilación simple y multiplataforma que soporta Visual Studio, ¡y es altamente personalizable!
Este tutorial tiene como objetivo enseñar lo siguiente:
Si ya estás familiarizado con C++ y CMake, no dudes en saltar directamente a la sección de empaquetado. De lo contrario, ¡sigue leyendo!
Primero, demostremos, de algún modo, que este sistema de compilación funciona y crea un ejecutable correctamente empaquetado. Una vez que tengas CMake y Visual Studio instalados, navega al directorio raíz de este repositorio con tu terminal preferida y ejecuta lo siguiente:``` $ mkdir build $ cd build $ cmake ../
Esto creará los archivos de proyecto necesarios para compilar el código del tutorial de packer. Luego ejecute:```
$ cmake --build ./ --config Release
Esto compilará el proyecto packer en modo Release. Luego, puedes ejecutar la siguiente prueba:```
$ ctest -C Release ./
Si todo va bien, test\_pack y test_unpack deberían tener éxito. Si quieres ver los resultados del empaquetado tú mismo, deberías ver esto:```
$ ./packed.exe
I'm just a little guy!
Hablemos de todas las piezas móviles que hicieron posible esto.
Así que ya sabemos cuáles son los componentes principales de un packer, pero ¿cómo podemos probar nuestro packer inmediatamente como parte del ciclo de desarrollo? Necesitaremos añadir un tercer binario a nuestro proyecto general para probar adecuadamente el proceso de empaquetado y desempacado de nuestro binario. Así que, en general, necesitamos tres proyectos:
También necesitamos una librería de compresión para asegurarnos de que el binario se comprime dentro del ejecutable stub. zlib funcionará bien para esto.
Nuestra jerarquía de proyectos, para empezar, debería verse así:``` packer/ +---+ CMakeLists.txt + dummy/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + stub/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + src/ | | | +---+ main.cpp | + zlib-1.2.13/ | +---+ CMakeLists.txt + ...
Nuestro main.cpp en cada carpeta puede simplemente verse así por ahora:```cpp
#include <iostream>
int main(int argc, char *argv[]) {
std::cout << "I'm just a little guy!" << std::endl;
return 0;
}
Ya debemos establecer que tenemos una cadena de dependencias simple con la que lidiar, la cual CMake resolverá fácilmente por nosotros con algo de instrumentación:
Comencemos con el proyecto raíz, el packer, para nuestra instrumentación de CMake.
CMake normalmente requiere una versión mínima con la que trabajar, ya que existe desde hace mucho tiempo y admite el uso a largo plazo de versiones anteriores. Después de eso, podemos declarar nuestro proyecto packer como un proyecto de C++.```cmake
cmake_minimum_required(VERSION 3.24)
project(packer CXX)
También queremos declarar nuestro packer como "MultiThreaded" (es decir, /MT) en lugar de "MultiThreadedDLL" (es decir, /MD) para no tener que preocuparnos por las dependencias de DLL en tiempo de ejecución.```cmake
# this line will mark our packer as MultiThreaded, instead of MultiThreadedDLL
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
La declaración entre corchetes angulares en la declaración de la variable se denomina expresión generadora y nos ayuda a resolver datos de tiempo de configuración cuando sea necesario; la verá mucho a lo largo de este archivo. Lo que hace esta expresión generadora es emitir la cadena Debug cuando detecta que la configuración es Debug, y no emite nada en caso contrario. Esto proporciona una biblioteca de tiempo de ejecución de MultiThreadedDebug cuando se selecciona un perfil de compilación de Debug, y MultiThreaded cuando se selecciona un perfil de compilación que no es de depuración, como Release. Consulte expresiones generadoras condicionales para comprender bien lo que sucede allí. Para más información sobre la variable CMAKE_MSVC_RUNTIME_LIBRARY, consulte la documentación de CMake.
CMake le permitirá organizar su código fuente en jerarquías como Visual Studio hace automáticamente al crear proyectos con su interfaz de usuario, y también buscará recursivamente en sus carpetas los nombres de archivo coincidentes. En nuestro archivo de configuración, establecemos una recursión global en los encabezados (.hpp), código (.cpp) y scripts de recursos (.rc). Para nuestro ejemplo, solo necesitamos main.cpp, pero esto es útil saberlo para proyectos más grandes.```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})
Aquí debo explicar que CMake se describe con más precisión como un **sistema de make**. Crea el "sistema de make" apropiado para el entorno dado según el compilador proporcionado. En Linux, esto sería un makefile para los compiladores detectados (o proporcionados). Para nosotros en Windows, esto produce un proyecto de Visual Studio compatible con nuestra versión de Visual Studio, lo que significa que una vez que creas el sistema de make de MSVC, ¡puedes usar Visual Studio para todo si así lo deseas! En última instancia, estás usando CMake para configurar Visual Studio. Así que lo que estás haciendo aquí es esencialmente crear los árboles de archivos de tu proyecto en Visual Studio que la GUI hace por ti al crear un proyecto.
A continuación, necesitamos agregar nuestros proyectos dependientes:```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)
Una cosa que mencioné antes es que el proyecto packer depende del proyecto stub. El packer, de alguna manera, necesita conservar el stub y manipularlo para finalmente obtener nuestro ejecutable empaquetado. Podemos usar archivos de recursos de Windows para finalmente incrustar nuestro ejecutable stub en nuestro binario packer, ¡sin importar la configuración de compilación! Además de esto, podemos usar CMake para generar esos archivos por nosotros, de modo que nuestras referencias a los ejecutables compilados sean sólidas dentro del proyecto CMake. ¡Generemos nuestros archivos de recursos por ahora para poder incluirlos en nuestro proyecto:```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` es la cadena que contiene tu directorio de build actual. Visual Studio vuelca los binarios en carpetas según su configuración, por lo que usamos la declaración de generador `$<CONFIG>` para obtener la configuración actual del build. También usamos la declaración de generador `$<TARGET_FILE:stub>` para generar el nombre del archivo ejecutable del binario stub una vez compilado. Cuando incluimos estos archivos en nuestro proyecto-- el archivo RC generado y la cabecera generada-- podemos integrar con éxito nuestro binario stub en el proyecto del packer.
A continuación, declaramos un ejecutable para nuestro proyecto del packer con los archivos recopilados anteriormente:```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")
Luego enlazamos la biblioteca zlib importada para el enlazador:```cmake
target_link_libraries(packer zlibstatic)
You can instead link just `zlib` if you really want the DLL of zlib.
Because we have generated files (and so does zlib as part of its build step), we need to include the dynamic directories in the include headers for the project. Because of a dependency on projects being in the root of a project in CMake, we also add the zlib include directories for our stub binary as well:```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"
)
Finalmente, resolvemos las cosas en nuestra cadena de dependencias informando a CMake sobre nuestra situación de dependencias: marcamos el stub como dependencia del packer y el packer como dependencia del dummy.```cmake
add_dependencies(packer stub) add_dependencies(dummy packer)
¡Los archivos CMake para [el stub](https://github.com/frank2/packer-tutorial/blob/main/stub/CMakeLists.txt) y [el dummy](https://github.com/frank2/packer-tutorial/blob/main/dummy/CMakeLists.txt) deberían ser bastante fáciles de entender ahora!
¡Lo bueno es que CMake puede encargarse de las pruebas por nosotros! Y a estas alturas, generar pruebas para nuestro packer es un proceso sencillo: podemos simplemente ejecutar un comando y, si el código de salida es 0, la prueba pasa. Para simplificar, digamos que empaquetamos un binario pasándolo como primer argumento del ejecutable. Queremos algo así:```
$ packer.exe dummy.exe
Para hacer eso con CMake es muy sencillo:```cmake
enable_testing() add_test(NAME test_pack COMMAND "$<TARGET_FILE:packer>" "$<TARGET_FILE:dummy>")
Finalmente, probar si el programa se desempaqueta o no es incluso más sencillo: ¡simplemente ejecuta la salida! No debería sorprenderte cuántos errores encontrarás que hacen fallar el programa simplemente al ejecutarlo cuando intentas emular el cargador. Digamos de nuevo, por simplicidad, que el binario debería generar "packed.exe" para un binario empaquetado. En ese caso, todo lo que tienes que hacer es esto:```cmake
add_test(NAME test_unpack
COMMAND "packed.exe")
Esto fallará de forma elegante si tu packer no logra generar el binario. Desafortunadamente, CMake para Visual Studio ignora la variable ADDITIONAL_CLEAN_FILES, por lo que tendrás que limpiar manualmente todos los archivos generados en tu sistema de compilación, incluidos los archivos stub.rc y stub.hpp generados anteriormente.
¡Felicidades! Se ha hecho mucho trabajo para compilar y probar tu packer con éxito. Ahora que hemos comido nuestras verduras, nuestro packer puede hacer lo siguiente:
¡Ahora podemos pasar a lo bueno!
Así que hemos configurado nuestro compilador para compilar el ejecutable stub como un recurso dentro de nuestro binario packer, pero ¿cómo conseguimos que un binario quede en estado empaquetado dentro del stub? No podemos añadirlo como recurso porque no podemos esperar que el usuario final del packer simplemente use un compilador; se supone que el ejecutable packer es una solución autónoma.
Una técnica que he usado mucho (aunque puede resultar obvia para el análisis) es añadir una nueva sección al binario stub para que finalmente se cargue en tiempo de ejecución. Esto terminará sirviendo también como un curso intensivo sobre el formato PE. Pero primero, ¿cómo extraemos los datos de los recursos?
Hay tres pasos básicos para obtener datos de recursos de un binario en tiempo de ejecución:
La siguiente función demuestra cómo buscar y adquirir un recurso de un binario determinado:```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]); }
Con nuestros datos en un vector fácilmente maleable, ahora podemos analizar la imagen stub y añadir nuevos datos a ella.
### Análisis de un archivo PE
Un ejecutable de Windows, en sus componentes más básicos, se divide en dos partes: sus *cabeceras* y sus *datos de sección*. Las cabeceras contienen mucha metadata importante para el proceso de carga, y los datos de sección son simplemente eso-- datos, que pueden ser código ejecutable (es decir, la sección `.text`) o datos arbitrarios (es decir, una sección `.data`). El inicio de todo ejecutable de Windows comienza con una estructura `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;
Aunque esta estructura parece tener muchas cosas, probablemente puedas deducir por el nombre que este encabezado es una reliquia de versiones anteriores de Windows y Microsoft DOS. Aquí, solo nos interesan dos valores de este encabezado: e_magic y e_lfanew. e_magic es simplemente el valor mágico del encabezado en la parte superior de la imagen, el "MZ" al comienzo del archivo. e_lfanew es el desplazamiento desde el inicio del archivo hasta los encabezados NT, que son los encabezados PE que contienen mucha más información de metadatos sobre el ejecutable. Podemos, por ejemplo, construir un validador de PE simple para nuestro empaquetador de la siguiente manera:```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` es una estructura bastante grande en general, así que no documentaré todo aquí, pero puedes encontrar todo lo que necesitas saber sobre ella en la [documentación de Microsoft sobre el encabezado](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_nt_headers64). Solo necesitaremos unos pocos miembros de estas estructuras de todos modos. Por ahora, deberíamos comprimir nuestro binario objetivo para añadirlo a los datos de nuestro stub.
Usar las funciones `compress` y `decompress` de zlib es muy sencillo. Si realmente quieres ponerte elegante trabajando con zlib, te sugiero usar las funciones `deflate`/`inflate`, que te permiten trabajar con flujos de compresión por fragmentos. Consulta la sección "Funciones avanzadas" del [manual de zlib](https://www.zlib.net/manual.html). Para este ejemplo, sin embargo, `compress` y `decompress` son suficientes.
Para empezar, obtenemos un valor de tamaño con la función `compressBound` de zlib sobre el tamaño de nuestro binario objetivo. Este valor de tamaño corresponde al máximo necesario para contener un flujo de datos comprimidos dado el tamaño de los datos. Luego podemos usar este valor para asignar un vector que contenga los datos comprimidos. La función `compress` eventualmente devuelve el tamaño real del búfer comprimido, al cual podemos redimensionar nuestro vector al tamaño adecuado.```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);
Tomémonos un momento para hablar sobre la alineación de datos. Se considera que un flujo de datos dado está alineado si su dirección o tamaño es divisible por un límite de alineación determinado. Por ejemplo, en los archivos PE, las secciones de datos en disco suelen estar alineadas al límite 0x400, mientras que en memoria están alineadas al límite 0x1000. Podemos determinar si un valor dado está alineado realizando un módulo de la alineación sobre el valor (es decir, value % alignment == 0). Los archivos PE pueden alinearse arbitrariamente a otros valores, y este valor está presente dentro del cargador PE y es importante para este en general. Alinear un valor dado con un límite determinado es una operación relativamente simple:```cpp
template
T align(T value, T alignment) {
auto result = value + ((value % alignment == 0) ? 0 : alignment - (value % alignment));
return result;
}
Esta función esencialmente rellena un valor potencialmente no alineado con el resto necesario para alinearlo correctamente a un límite determinado.
Para agregar correctamente datos arbitrarios a nuestro archivo PE, debemos prestar especial atención a la *alineación de archivo* en particular-- podemos calcular los valores alineados correctos para el archivo PE cuando esté en memoria más tarde, pero por ahora, al agregar nuestros datos, necesitamos alinear nuestro archivo al límite de alineación de archivo. En el siguiente código, obtenemos los encabezados, luego los límites de alineación de archivo y de alineación de sección, y procedemos a alinear nuestros datos del stub al límite del archivo y anexar nuestra sección recién empaquetada.```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());
Ahora, es posible que hayamos añadido los datos de nuestra sección de acuerdo con los límites de las secciones del archivo, pero nuestro ejecutable stub todavía no es consciente de esta sección en el archivo PE. No solo necesitamos analizar la tabla de secciones del archivo PE, sino también añadir una nueva entrada que apunte a nuestra sección. Esta es la razón de la variable raw_offset.
Primero, podemos incrementar el número de secciones fácilmente actualizando NumberOfSections. Normalmente, los datos después de la última tabla de secciones están puestos a cero, por lo que podemos sobrescribir los datos puestos a cero con nuestra nueva sección fácilmente.```cpp
// increment the number of sections in the file header
auto section_index = nt_header->FileHeader.NumberOfSections;
++nt_header->FileHeader.NumberOfSections;
A continuación, necesitamos obtener un puntero a la propia tabla de secciones. Si bien técnicamente sigue inmediatamente al encabezado opcional de los encabezados NT, el tamaño del encabezado opcional está determinado en realidad por el valor `SizeOfOptionalHeader` del encabezado de archivo NT. Por lo tanto, para llegar allí, necesitamos calcular un puntero desde el inicio de la estructura `OptionalHeader` hasta el desplazamiento proporcionado por el valor `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
);
Finalmente, estamos listos para comenzar a agregar los metadatos de nuestra sección. Este es nuestro encabezado de sección de 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;
Las variables particulares que nos interesan para el encabezado de nuestra nueva sección son `Name`, `VirtualSize`, `VirtualAddress`, `SizeOfRawData`, `PointerToRawData` y `Characteristics`. En este punto, debes ser consciente de que la razón por la que necesitas conocer dos tipos de alineación-- alineación de archivo y alineación de memoria-- es porque hay dos estados de memoria diferentes para un ejecutable PE dado: cómo se ve en *disco* y cómo se ve en *memoria*, como resultado del proceso de carga. Es posible configurar un archivo PE determinado para que tenga el mismo diseño de memoria tanto si está cargado como si no, pero no es una configuración frecuente.
La variable `Name` es la etiqueta de 8 bytes que puedes asignar a tu nueva sección. He elegido `.packed`, ya que es una cadena ASCII de 7 bytes y cabe perfectamente en el búfer.
`VirtualAddress` se refiere al desplazamiento de una sección determinada en memoria. También se conoce como "dirección virtual relativa", o RVA. `VirtualSize` se refiere al tamaño de la sección en memoria. (Cabe señalar que MSVC compila este valor como el valor de tamaño no alineado de la sección, por lo que también seguimos esta convención en nuestra sección). `PointerToRawData` se refiere al desplazamiento de una sección determinada en disco, y `SizeOfRawData` se refiere al tamaño de la sección en disco.
`Characteristics` es complicado y puede referirse a que una sección sea legible, escribible o ejecutable, además de otros indicadores; consulta la sección de características de [la documentación de `IMAGE_SECTION_HEADER`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_section_header). Por ahora, debes saber que todo lo que necesitamos es que la sección sea legible y esté marcada como contenedora de datos inicializados.
Con todo esto en mente, ¡ya podemos crear nuestra nueva sección!```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;
Sin embargo, esto ahora plantea un pequeño problema: el tamaño de la imagen cambió. Uno pensaría que no sería un problema, pero en el encabezado opcional del encabezado NT hay una variable llamada SizeOfImage que determina cuánto espacio necesita el cargador para asignar nuestro ejecutable. No obstante, esto tiene una solución sencilla: el tamaño que necesitamos para nuestra imagen es el tamaño de la última sección alineada al límite de alineación de secciones.```cpp
// calculate the new size of the image.
nt_header->OptionalHeader.SizeOfImage = align(virtual_offset + virtual_size, section_alignment);
¡Y eso es todo! Hemos añadido con éxito nuestro binario comprimido como una nueva sección para que nuestro stub lo descomprima y cargue más adelante. Ahora simplemente podemos guardar la imagen modificada del stub en disco.```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();
¡Felicitaciones! Hasta ahora hemos logrado lo siguiente:
Estamos a mitad de camino de escribir nuestro packer. Ahora podemos pasar a la parte posiblemente más difícil del proceso: desarrollar el binario stub.
Si bien los pormenores de escribir un stub de desempaquetado pueden complicarse bajo el capó, fundamentalmente se reduce a unos pocos pasos:
Este proceso es tan simple que nuestra rutina principal es solo un puñado de funciones:```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; }
### Leyendo nuestro PE desde la memoria
Primero, necesitamos obtener de alguna manera los datos de la sección creada por nuestro packer desde el binario en ejecución. ¿Es posible obtener los encabezados del binario en ejecución en tiempo de ejecución? ¡Sí, absolutamente! [`GetModuleHandleA`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlea), con un argumento nulo, en última instancia devuelve un puntero a los encabezados de nuestro PE en ejecución. Así que en tiempo de ejecución, tienes acceso fácil a la imagen tal como existe en memoria. Esto es lo que hace tan atractivo agregar una nueva sección a nuestro binario: podemos analizar muy fácilmente nuestra sección objetivo desde el binario.
Con lo que hemos aprendido sobre el análisis de la tabla de secciones, esta sección de código debería ser fácil de entender:```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);
}
A continuación, necesitamos descomprimir nuestros datos stub del binario. zlib recomienda que codifiquemos el tamaño de la carga útil original descomprimida de manera que sea accesible de algún modo para la rutina de descompresión, razón por la cual codificamos el tamaño del binario descomprimido en el encabezado de nuestros datos empaquetados. Así que obtenemos un puntero a nuestros datos descomprimidos, creamos un nuevo buffer que pueda contener los datos descomprimidos y procedemos a llamar a la función de descompresión 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;
Como puedes ver, `get_image` era una función relativamente simple al final del día. Conseguimos extraer nuestro binario objetivo de la sección añadida, así que ahora tenemos que cargarlo.
### Cargando nuestro PE para ejecución
El cargador de ejecutables de Windows hace muchas cosas diferentes bajo el capó y soporta muchas configuraciones de ejecutables diferentes. Es probable que encuentres una variedad de errores con el empaquetador que produce este tutorial si exploras más allá del binario de ejemplo, ya que la configuración que construiremos hoy es técnicamente muy mínima. Pero para establecer ese mínimo absoluto hasta la ejecución, necesitamos hacer lo siguiente para cargar un ejecutable moderno de Windows:
* asignar la imagen que contiene la representación en memoria de la imagen del ejecutable
* mapear las secciones de nuestra imagen ejecutable, incluidos los encabezados, a esa imagen asignada
* resolver las importaciones en tiempo de ejecución a otras bibliotecas que nuestro binario necesita
* remapear la imagen del binario para que una variedad de direcciones en nuestra imagen apunten a donde se supone que deben apuntar
Comencemos con la función `load_image`. Para empezar, necesitamos adquirir nuestra tabla de secciones del binario empaquetado. Eventualmente, esta se mapeará sobre nuestra imagen recién asignada. Para un búfer de ejecutable adecuado, la asignación es muy simple: toma el valor `SizeOfImage` de nuestros encabezados de sección y crea un nuevo búfer con [`VirtualAlloc`](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualalloc) que cree una imagen legible, escribible y ejecutable a la que podamos mapear.```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);
}
Con nuestro búfer asignado, lo que necesitamos hacer es copiar las cabeceras y las secciones a él. Esto debe ajustarse a la variable SectionAlignment de antes. Afortunadamente, la forma en que preparamos nuestra sección -- y la forma en que las otras secciones están preparadas -- ya están alineadas con el límite de SectionAlignment. Baste decir que el resultado final es aritmética de punteros simple: copiar nuestra imagen objetivo en el desplazamiento PointerToRawData, dentro de nuestra imagen cargada en el desplazamiento VirtualAddress.
Opcionalmente, puedes copiar las cabeceras PE a la parte superior de la imagen. Facilita el desarrollo conservar las cabeceras originales, pero querer eliminarlas es un buen paso hacia la creación de un empaquetador hostil al análisis. Copiamos las cabeceras aquí por facilidad de uso.```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;
Así que la parte fácil del proceso de carga ha terminado: hemos recuperado nuestro binario de la memoria, lo hemos desempaquetado y hemos reasignado sus secciones a una región de memoria ejecutable. Con nuestra imagen preparada, podemos profundizar en los pormenores del proceso de carga.
### Resolución de importaciones de API
Dentro de las cabeceras opcionales hay algo llamado *directorio de datos*. Este directorio contiene mucha información diferente sobre el ejecutable, como símbolos exportados por la imagen y recursos como iconos y mapas de bits. En este tutorial vamos a analizar dos directorios de datos: el **directorio de importaciones** y el **directorio de reubicaciones**. Cada directorio está codificado de forma fija, y sus índices se pueden encontrar [en la documentación de la cabecera opcional](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header32) (desplázate hacia abajo hasta la descripción de `DataDirectory`). Un directorio de datos está presente si su valor de `VirtualAddress` no es nulo.```c
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
Obtenemos punteros a los directorios de datos mediante el casting de la RVA proporcionada. Por ejemplo, así es como finalmente se obtiene la tabla de importaciones desde el directorio de datos de importación:```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);
Para resolver las importaciones de API, el cargador analiza este directorio y, a continuación, procede a cargar las librerías necesarias y adquirir sus funciones importadas. Por suerte, es un directorio relativamente fácil de analizar.
Comienza con una estructura de descriptor de importación:```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;
Lo que más nos preocupa son dos variables con nombres algo confusos: OriginalFirstThunk y FirstThunk. OriginalFirstThunk contiene información sobre las importaciones que desea este ejecutable en relación con la DLL indicada por el RVA Name. Para añadir más confusión, FirstThunk también lo hace. ¿Qué las diferencia? FirstThunk contiene las importaciones una vez que han sido resueltas. Esta resolución la realiza la infame función GetProcAddress.
Nuestros thunks son estructuras de datos adicionales con las que lidiar:```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;
Esta estructura de datos cubre tanto thunks de importación como de exportación, por lo que `ForwarderString` puede ignorarse. Un thunk de importación puede ser un `Ordinal` o un RVA a otra estructura, `IMAGE_IMPORT_BY_NAME`. Un *ordinal* es simplemente un desplazamiento en la tabla de exportación del DLL dado. Una estructura `IMAGE_IMPORT_BY_NAME` se ve así:```c
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
Este es un ejemplo de estructura variádica. Se aprovecha de la falta de comprobación de límites en el acceso a arrays para crear estructuras que pueden ser de tamaño variable. Se espera que Name, en este caso, sea una cadena C terminada en cero.
Debido a que los datos binarios no contienen tipos, un ordinal y una importación se diferencian por el bit más significativo en la entrada del thunk. El ordinal se encuentra en la mitad inferior del entero tanto en implementaciones de 32 bits como de 64 bits.
Nuestra tabla de importaciones funciona como una cadena C: su última entrada es un OriginalFirstThunk terminado en nulo para indicar el final de las posibles importaciones. Nuestros datos de thunk funcionan de la misma manera, terminando con una entrada nula en el arreglo de thunks.
Para ponerlo todo junto, así es como abordamos el análisis de la tabla de importaciones en pseudocódigo:``` 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
Curiosamente, a pesar de que `GetProcAddress` está tipada con una cadena C como argumento de la función, Windows espera que importes por ordinal simplemente convirtiendo el valor ordinal en una cadena C. Vete a saber.
Con estas explicaciones en mente, este bucle `while` ahora debería tener sentido:```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;
}
Con las importaciones resueltas, ¡ahora podemos pasar a la pieza final: tratar con el directorio de reubicaciones!
El siguiente directorio de datos a abordar es el llamado directorio de reubicaciones. Este directorio se encarga de traducir las direcciones absolutas dentro del código a sus nuevos valores base. Este proceso esencialmente implementa algo que quizás conozcas llamado aleatorización del diseño del espacio de direcciones, pero también se encarga de garantizar que los espacios de direcciones de las DLL no colisionen entre sí.
Primero, tenemos que asegurarnos de que nuestro binario realmente sea capaz de mover bases de direcciones. A veces, especialmente en binarios más antiguos, esto no está habilitado. Dentro de las características de un ejecutable de Windows dado se encuentran sus características, denominadas de forma confusa DllCharacteristics en la cabecera opcional. Nos interesa la característica de "base dinámica". Existe una forma especial de desempaquetar binarios sin ASLR, que no estamos cubriendo, por lo que es un error si nuestro binario no lo soporta. (Esto es mejor como un error en tu empaquetador, no en tu stub, pero por facilitar el flujo de la educación sobre la cabecera PE, se ha colocado aquí en su lugar.)```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); }
A continuación, necesitamos calcular el *delta de direcciones*. Esto es simplemente la diferencia entre la variable `ImageBase` de la imagen y la dirección base de la imagen virtual. Se utiliza para ajustar rápidamente los valores de las direcciones codificadas en el binario.```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;
Ahora estamos listos para abordar la tabla de reubicación.```c typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; DWORD SizeOfBlock; // WORD TypeOffset[1]; } IMAGE_BASE_RELOCATION;
Observa el array `TypeOffset` comentado, ¡en realidad es relevante aquí! Una tabla de reubicaciones consiste en bloques de offsets que contienen direcciones a ajustar, identificados por el RVA `VirtualAddress`. El array `TypeOffset` contiene valores de palabra codificados que contienen el tipo de reubicación así como el offset desde `VirtualAddress` a ajustar. En cuanto al tipo de reubicación, para binarios de 64 bits, solo nos interesa un tipo de reubicación. Desafortunadamente, esta estructura no es realmente una struct variádica, así que tenemos que hacer algo de aritmética de punteros para obtener el array `TypeOffset`.
Como se mencionó, el array `TypeOffset` contiene palabras codificadas. Los 4 bits superiores (máscara `0xF000`) contienen el tipo de reubicación, que se puede encontrar en la [sección "base relocation types"](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) de la documentación del formato PE. Los 12 bits inferiores (máscara `0x0FFF`) contienen el offset desde el argumento `VirtualAddress` a ajustar.
Explicarlo es una tarea pesada y al final resulta muy confuso. Obtener un puntero a una dirección a reubicar se ve así:```cpp
auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);
Y ajustar esa dirección se ve así:```cpp *ptr += delta;
Por muy complicado que sea explicar el directorio de reubicación, las operaciones son en realidad muy simples de entender en el código.
Avanzar al siguiente bloque de datos de reubicación es fácil gracias a la variable `SizeOfBlock`. Este bloque contiene el tamaño de nuestro encabezado *así como* el tamaño del array `TypeOffset`. Si `TypeOffset` fuera en su lugar un array de tamaño estático, simplemente avanzaríamos a la siguiente entrada de reubicación añadiendo la llamada a `sizeof` del encabezado de reubicación.
Con todo esto explicado, deberías ser capaz de entender este código de reubicación:```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
);
}
¡Felicidades! ¡Nuestra imagen ya está lista para ejecutarse! Hemos logrado mucho hasta ahora:
¡Ahora estamos listos para ejecutar nuestro binario desempaquetado!
Volvamos ahora a nuestro bucle principal:```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; }
Como puedes ver, transferir la ejecución es muy sencillo, aunque requiere conocimiento de [punteros a función](https://en.wikipedia.org/wiki/Function_pointer). Estos son nuestros fragmentos relevantes:```cpp
// acquire and call the entrypoint
auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint;
reinterpret_cast<void(*)()>(entrypoint)();
AddressOfEntryPoint es, como puedes suponer, un RVA en la entrada de código de nuestra imagen cargada. A pesar de lo que puedas saber sobre los puntos de entrada main, el punto de entrada bruto de un binario dado no está tipado: tu compilador de C++ es el principal responsable de configurar el entorno de código para pasar argumentos a tus funciones main esperadas, ya sean main o WinMain.
Fundamentalmente, así es como se declara un puntero a función:```c return_type (*variable_name)(int arg1, int arg2, ...)
Por lo tanto, nuestro puntero de función para llamar a nuestro punto de entrada—sin tener un tipo asociado—se ve así:```c
void (*entrypoint)()
Como cast, se reduce a esto:```c void(*)()
Poniendo todo junto, podemos simplemente convertir nuestro punto de entrada en un puntero a función y llamarlo en la misma línea, así:```cpp
reinterpret_cast<void(*)()>(entrypoint)();
Con todo cargado correctamente, deberías ver ejecutarse tu programa empaquetado. En nuestro caso, como empaquetamos nuestro ejecutable ficticio, simplemente muestra un mensaje:``` $ ./packed.exe I'm just a little guy!
¡Felicitaciones! ¡Ya está! ¡Acabas de escribir un packer de Windows!
## Ejercicios adicionales
* **Ataca al analista**: Aprende a implementar [algunas técnicas anti-debug](https://anti-reversing.com/Downloads/Anti-Reversing/The_Ultimate_Anti-Reversing_Reference.pdf) y refuerza tu packer.
* **Amplía tu compatibilidad**: Aprende a implementar un binario no reubicable, o aprende a implementar más directorios como el directorio de almacenamiento local de subprocesos (`IMAGE_TLS_DIRECTORY`) y el directorio de recursos (`IMAGE_RESOURCE_DIRECTORY`). Dado que la documentación es escasa a este nivel, puedes ver mis implementaciones en [exe-rs](https://github.com/exe-rs).
* **Ofusca tu stub**: Intenta averiguar cómo va a desempaquetar tu binario el analista y dificulta el desempaquetado, por ejemplo [borrando las cabeceras](https://github.com/frank2/packer-tutorial/blob/main/stub/src/main.cpp#L84).