
Руководство о том, как написать пакер для Windows!

Выглядит как пугающее чтиво? Попробуйте версию в виде презентации, в которой кратко изложен этот readme. Видео на YouTube скоро появится!
Паковщик — это программа, которая распаковывает и запускает другую программу в своём адресном пространстве (или иногда в адресном пространстве другого процесса). Иногда он известен как вектор атак на среду анализа, такие как отладчики и виртуальные песочницы. В основном он используется для нескольких вещей:
По сути, паковщик выполняет лишь несколько основных шагов:
Так же просто, паковщик состоит всего из нескольких частей:
Создание паковщика может быть непростым делом, поскольку заглушку нужно как-то собрать и импортировать в исполняемый файл паковщика. Для Windows изучение системы сборки Visual Studio за пределами простой компиляции может быть утомительным занятием. К счастью, CMake предоставляет простую кроссплатформенную систему сборки, поддерживающую Visual Studio, и она хорошо настраивается!
Это руководство ставит целью научить следующему:
Если вы уже знакомы с C++ и CMake, можете сразу перейти к разделу об упаковке. В противном случае читайте дальше!
Сначала давайте как-то докажем, что эта система сборки работает и создаёт правильно упакованный исполняемый файл. После установки CMake и Visual Studio перейдите в корневой каталог этого репозитория с помощью вашего любимого терминала и выполните следующее:``` $ mkdir build $ cd build $ cmake ../
Это создаст необходимые файлы проекта для сборки кода из руководства по Packer. Затем выполните:```
$ cmake --build ./ --config Release
Это соберёт проект packer в режиме Release. Затем вы можете запустить следующий тест:```
$ ctest -C Release ./
Если всё пройдёт хорошо, test\_pack и test_unpack должны выполниться успешно. Если хотите сами увидеть результаты упаковки, вы увидите следующее:```
$ ./packed.exe
I'm just a little guy!
Давайте поговорим обо всех составных частях, которые сделали это возможным.
Итак, мы знаем, каковы основные компоненты упаковщика, но как мы можем протестировать наш упаковщик сразу в рамках цикла разработки? Нам нужно добавить третий бинарный файл в наш общий проект, чтобы должным образом протестировать процесс упаковки и распаковки нашего бинарного файла. Итак, всего нам понадобится три проекта:
Нам также понадобится библиотека сжатия, чтобы гарантировать, что бинарный файл сжимается в исполняемый файл заглушки. Для этого отлично подойдёт zlib.
Иерархия нашего проекта на первых порах должна выглядеть так:``` packer/ +---+ CMakeLists.txt + dummy/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + stub/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + src/ | | | +---+ main.cpp | + zlib-1.2.13/ | +---+ CMakeLists.txt + ...
Наш main.cpp в каждой папке пока может просто выглядеть так:```cpp
#include <iostream>
int main(int argc, char *argv[]) {
std::cout << "I'm just a little guy!" << std::endl;
return 0;
}
Итак, нам следует установить, что мы имеем дело с простой цепочкой зависимостей, которую CMake аккуратно разрешит для нас с помощью некоторой инструментовки:
Начнём с корневого проекта — packer — для нашей инструментовки CMake.
CMake обычно требует указания минимальной версии, поскольку он существует уже давно и поддерживает долгосрочное использование предыдущих версий. После этого мы можем объявить наш проект packer как C++ проект.```cmake
cmake_minimum_required(VERSION 3.24)
project(packer CXX)
Мы также хотим объявить наш пакер как "MultiThreaded" (т.е. /MT) вместо "MultiThreadedDLL" (т.е. /MD), чтобы нам не приходилось беспокоиться о зависимостях от DLL времени выполнения.```cmake
# this line will mark our packer as MultiThreaded, instead of MultiThreadedDLL
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
Утверждение в угловых скобках в операторе установки переменной называется генераторным выражением и помогает нам разрешать данные времени конфигурации там, где это необходимо; вы будете часто встречать его в этом файле. Что делает это генераторное выражение — оно выводит строку Debug, когда обнаруживает, что конфигурация — Debug, и ничего не выводит в противном случае. Это обеспечивает библиотеку времени выполнения MultiThreadedDebug, когда выбран профиль компиляции Debug, и MultiThreaded, когда выбран не отладочный профиль компиляции, например Release. См. условные генераторные выражения, чтобы хорошо понять, что там происходит. Подробнее о переменной CMAKE_MSVC_RUNTIME_LIBRARY см. документацию CMake.
CMake позволяет организовывать исходный код в иерархии так же, как Visual Studio автоматически делает при создании проектов через свой интерфейс, а также рекурсивно ищет в ваших папках файлы с подходящими именами. В нашем конфигурационном файле мы задаём глобальный рекурсивный поиск для заголовочных файлов (.hpp), кода (.cpp) и скриптов ресурсов (.rc). Для нашего примера нам действительно нужен только main.cpp, но это полезно знать для более крупных проектов.```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})
Здесь я должен объяснить, что CMake более точно описывается как **система сборки**. Он создаёт правильную «систему сборки» для данного окружения на основе заданного компилятора. В Linux это будет makefile для обнаруженных (или указанных) компиляторов. Для нас, работающих в Windows, это создаёт проект Visual Studio, совместимый с нашей версией Visual Studio, то есть после создания MSVC-системы сборки вы при желании можете просто использовать Visual Studio для всего! В конечном счёте вы используете CMake для настройки Visual Studio. То есть, по сути, вы создаёте в Visual Studio те деревья файлов вашего проекта, которые графический интерфейс создаёт за вас при создании проекта.
Далее нам нужно добавить наши зависимые проекты:```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)
Одна вещь, о которой я упоминал ранее, заключается в том, что проект packer зависит от проекта stub. Каким-то образом packer должен сохранить stub и манипулировать им, чтобы в итоге получить наш упакованный исполняемый файл. Мы можем использовать файлы ресурсов Windows, чтобы в конечном счёте внедрить наш исполняемый файл stub в бинарник packer независимо от конфигурации сборки! Кроме того, мы можем использовать CMake для генерации этих файлов, чтобы наши ссылки на собранные исполняемые файлы были корректными в рамках проекта CMake! Давайте пока сгенерируем наши файлы ресурсов, чтобы включить их в наш проект:```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` — это строка, содержащая ваш текущий каталог сборки. Visual Studio помещает двоичные файлы в папки в зависимости от их конфигурации, поэтому мы используем оператор генератора `$<CONFIG>`, чтобы получить текущую конфигурацию сборки. Также мы используем оператор генератора `$<TARGET_FILE:stub>`, чтобы вывести имя исполняемого файла stub-двоичного файла после его компиляции. Когда мы включаем эти файлы в наш проект — сгенерированный RC-файл и сгенерированный заголовочный файл — мы успешно интегрируем наш stub-двоичный файл в проект упаковщика.
Далее мы объявляем исполняемый файл для нашего проекта упаковщика с файлами, собранными ранее:```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")
Затем мы подключаем импортированную библиотеку zlib для компоновщика:```cmake
target_link_libraries(packer zlibstatic)
Вы можете вместо этого просто слинковать `zlib`, если вам действительно нужна DLL-библиотека zlib.
Поскольку у нас есть сгенерированные файлы (и у zlib они тоже есть как часть его шага сборки), нам нужно включить динамические каталоги в заголовочные файлы включения для проекта. Из-за зависимости от размещения проектов в корне проекта в CMake мы также добавляем каталоги включения zlib для нашего заглушечного бинарного файла:```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"
)
Наконец, мы упорядочиваем нашу цепочку зависимостей, сообщая CMake о ситуации с зависимостями: помечаем заглушку как зависимость паковщика, а паковщик — как зависимость макета.```cmake
add_dependencies(packer stub) add_dependencies(dummy packer)
Файлы CMake для [заглушки](https://github.com/frank2/packer-tutorial/blob/main/stub/CMakeLists.txt) и [пустышки](https://github.com/frank2/packer-tutorial/blob/main/dummy/CMakeLists.txt) теперь должны быть довольно понятны!
Что здорово, CMake может управлять тестированием за нас! И на этом этапе создание тестов для нашего упаковщика — простой процесс: мы можем просто выполнить команду, и если код возврата равен 0, тест проходит. Для простоты предположим, что мы упаковываем бинарный файл, передавая его первым аргументом исполняемому файлу. Нам нужно что-то вроде этого:```
$ packer.exe dummy.exe
Сделать это с помощью CMake очень просто:```cmake
enable_testing() add_test(NAME test_pack COMMAND "$<TARGET_FILE:packer>" "$<TARGET_FILE:dummy>")
Наконец, проверить, распаковывается ли программа, ещё проще: просто запустите выходной файл! Не стоит удивляться, сколько ошибок вы встретите, которые роняют программу просто при запуске, когда вы пытаетесь эмулировать загрузчик! Скажем ещё раз, для простоты, что бинарный файл должен выводиться в "packed.exe" для упакованного бинарного файла. В таком случае всё, что вам нужно сделать, это:```cmake
add_test(NAME test_unpack
COMMAND "packed.exe")
Это корректно завершится ошибкой, если ваш упаковщик не смог создать бинарный файл. К сожалению, CMake для Visual Studio игнорирует переменную ADDITIONAL_CLEAN_FILES, так что вам придётся вручную очищать все сгенерированные файлы в вашей системе сборки, включая созданные выше файлы stub.rc и stub.hpp.
Поздравляем! Проделана большая работа, чтобы успешно собрать и протестировать ваш упаковщик. Теперь, когда мы выполнили рутинную часть, наш упаковщик умеет следующее:
Теперь можно перейти к самому интересному!
Итак, мы успешно настроили компилятор на компиляцию исполняемого файла заглушки как ресурса в бинарный файл упаковщика, но как нам получить бинарный файл в упакованном состоянии в заглушку? Мы не можем добавить его как ресурс, потому что мы не можем ожидать, что конечный пользователь упаковщика будет просто использовать компилятор; исполняемый файл упаковщика должен быть автономным решением.
Один из приёмов, который я часто использовал (хотя он может быть очевиден при анализе), — это добавление новой секции в бинарный файл заглушки, которая впоследствии будет загружаться во время выполнения. Это заодно послужит ускоренным курсом по формату PE. Но сначала: как нам извлечь данные из ресурсов?
Есть три основных шага для получения данных ресурса из бинарного файла во время выполнения:
Следующая функция демонстрирует, как выполнять поиск и получать ресурс из заданного бинарного файла:```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]); }
Имея наши данные в легко изменяемом векторе, мы теперь можем разобрать образ заглушки и добавить к нему новые данные.
### Разбор PE-файла
Исполняемый файл Windows в своих базовых компонентах делится на две части: его *заголовки* и *данные секций*. Заголовки содержат много метаданных, важных для процесса загрузки, а данные секций — это просто данные, которые могут быть исполняемым кодом (например, секция `.text`) или произвольными данными (например, секция `.data`). Начало каждого исполняемого файла Windows начинается со структуры `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;
Хотя эта структура выглядит так, будто в ней много всего, по названию можно догадаться, что этот заголовок — пережиток прошлых версий Windows и Microsoft DOS. Здесь нас интересуют только два значения в этом заголовке: e_magic и e_lfanew. e_magic — это просто магическое значение заголовка в начале образа, «MZ» в начале файла. e_lfanew — это смещение от начала файла до NT-заголовков, то есть PE-заголовков, содержащих гораздо больше метаданных об исполняемом файле. Мы можем, например, создать простой валидатор PE для нашего упаковщика следующим образом:```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` — довольно большая структура в целом, поэтому я не буду документировать её здесь полностью, но всё, что вам нужно о ней знать, можно найти в [документации Microsoft по этой структуре](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_nt_headers64). В любом случае, из этих заголовков нам понадобятся лишь несколько полей структуры. А пока мы сожмём наш целевой бинарный файл для добавления в данные нашей заглушки.
Использовать функции zlib `compress` и `decompress` очень просто. Если вы хотите по-настоящему продвинутой работы с zlib, я рекомендую обратиться к функциям `deflate`/`inflate`, которые позволяют работать с потоками сжатия по частям. Смотрите раздел "Advanced Functions" в [руководстве по zlib](https://www.zlib.net/manual.html). Впрочем, для этого примера будет достаточно `compress` и `decompress`.
Для начала мы получаем значение размера с помощью функции zlib `compressBound` от размера нашего целевого бинарного файла. Это значение соответствует максимальному размеру, необходимому для размещения сжатого потока данных с учётом размера данных. Затем это значение можно использовать для выделения вектора под сжатые данные. Функция `compress` в итоге возвращает фактический размер сжатого буфера, и уже до этого размера мы можем изменить размер нашего вектора.```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);
Давайте на минуту поговорим о выравнивании данных. Некий поток данных считается выровненным, если его адрес или размер делится на заданную границу выравнивания. Например, в PE-файлах разделы данных на диске обычно выровнены по границе 0x400, тогда как в памяти они выровнены по границе 0x1000. Мы можем определить, выровнено ли данное значение, выполнив взятие остатка от деления значения на выравнивание (т.е. value % alignment == 0). PE-файлы могут быть произвольно выровнены по другим значениям, и это значение присутствует внутри самого файла и важно для загрузчика PE в целом. Выравнивание заданного значения по заданной границе — относительно простая операция:```cpp
template
T align(T value, T alignment) {
auto result = value + ((value % alignment == 0) ? 0 : alignment - (value % alignment));
return result;
}
Эта функция по сути дополняет потенциально невыровненное значение остатком, необходимым для правильного выравнивания по заданной границе.
Чтобы корректно добавить произвольные данные в наш PE-файл, нам нужно учитывать в первую очередь *выравнивание файла* — позже мы сможем вычислить корректные выровненные значения для PE-файла, когда он будет в памяти, но сейчас при добавлении наших данных нам нужно выровнять файл по границе выравнивания файла. В следующем коде мы получаем заголовки, затем границы выравнивания файла и выравнивания секций, после чего выравниваем наши данные-заглушку по границе файла и добавляем нашу новую упакованную секцию.```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());
Теперь мы могли бы добавить данные для нашего раздела в соответствии с границами разделов файла, но наш исполняемый файл-заглушка всё ещё не знает об этом разделе в PE-файле. Нам нужно не только разобрать таблицу разделов PE-файла, но и добавить новую запись, указывающую на наш раздел. Именно поэтому существует переменная raw_offset.
Сначала мы можем легко увеличить количество разделов, обновив NumberOfSections. Обычно данные после последней таблицы разделов обнулены, поэтому мы можем без проблем перезаписать обнулённые данные нашим новым разделом.```cpp
// increment the number of sections in the file header
auto section_index = nt_header->FileHeader.NumberOfSections;
++nt_header->FileHeader.NumberOfSections;
Далее нам нужно получить указатель на саму таблицу разделов. Формально она следует сразу за дополнительным заголовком NT-заголовков, однако размер дополнительного заголовка определяется значением `SizeOfOptionalHeader` из файлового заголовка NT. Поэтому, чтобы добраться до неё, нам нужно вычислить указатель от начала структуры `OptionalHeader` до смещения, заданного значением `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
);
Наконец, мы готовы начать добавлять метаданные нашей секции. Это заголовок секции 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;
Переменные, которые нас интересуют для нашего нового заголовка секции: `Name`, `VirtualSize`, `VirtualAddress`, `SizeOfRawData`, `PointerToRawData` и `Characteristics`. На этом этапе вам следует понимать, что причина, по которой нужно знать о двух типах выравнивания — выравнивание файла и выравнивание в памяти — заключается в том, что для заданного PE-исполняемого файла существуют два разных состояния памяти: как он выглядит на *диске* и как он выглядит в *памяти* в результате процесса загрузки. Можно настроить PE-файл так, чтобы его компоновка в памяти была одинаковой независимо от того, загружен он или нет, но такая конфигурация встречается нечасто.
Переменная `Name` — это 8-байтовая метка, которую вы можете присвоить новой секции. Я выбрал `.packed`, поскольку это 7-байтовая ASCII-строка, которая идеально помещается в буфер.
`VirtualAddress` — это смещение данной секции в памяти. Также известен как «относительный виртуальный адрес», или RVA. `VirtualSize` — это размер секции в памяти. (Примечательно, что MSVC компилирует это значение как невыровненный размер секции, поэтому мы следуем этому соглашению и в нашей секции.) `PointerToRawData` — это смещение данной секции на диске, а `SizeOfRawData` — это размер секции на диске.
`Characteristics` — сложный параметр, который может указывать на то, что секция доступна для чтения, записи или исполнения, а также на другие признаки; см. раздел характеристик в [документации `IMAGE_SECTION_HEADER`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_section_header). Пока вам достаточно знать, что нам нужно лишь, чтобы секция была доступна для чтения и помечена как содержащая инициализированные данные.
С учётом всего этого мы можем создать нашу новую секцию!```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;
Однако теперь это создает небольшую проблему: размер образа изменился. Можно было бы подумать, что это не будет проблемой, но в необязательном заголовке NT-заголовка есть переменная SizeOfImage, которая определяет, сколько места загрузчику нужно выделить для нашего исполняемого файла. Впрочем, это простое исправление: размер, который нам нужен для нашего образа, — это размер последней секции, выровненный по границе выравнивания секций.```cpp
// calculate the new size of the image.
nt_header->OptionalHeader.SizeOfImage = align(virtual_offset + virtual_size, section_alignment);
И вот всё! Мы успешно добавили наш сжатый бинарный файл как новую секцию для нашей заглушки, которую она в конечном итоге распакует и загрузит. Теперь мы можем просто сохранить изменённый образ заглушки на диск.```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();
Поздравляем! На данный момент мы выполнили следующее:
Мы на полпути к завершению написания нашего упаковщика! Теперь мы можем перейти к, пожалуй, самой сложной части процесса: разработке stub-бинарника.
Хотя тонкости написания распаковочного stub могут быть сложными внутренне, по сути всё сводится к нескольким шагам:
Этот процесс настолько прост, что наша основная процедура — это всего лишь несколько функций:```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; }
### Читаем наш PE из памяти
Для начала нам нужно каким-то образом получить данные из секции, созданной нашим упаковщиком, из запущенного бинарника. Можно ли получить заголовки запущенного бинарника во время выполнения? Да, абсолютно! [`GetModuleHandleA`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlea) с аргументом null в итоге возвращает указатель на заголовки нашего запущенного PE! Так что во время выполнения у вас есть лёгкий доступ к образу, каким он существует в памяти. Именно это делает добавление новой секции в наш бинарник таким привлекательным: мы можем очень легко разобрать нашу целевую секцию из бинарника.
Учитывая то, что мы узнали о разборе таблицы секций, этот фрагмент кода должен быть легко понятен:```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);
}
Далее нам нужно распаковать данные заглушки из бинарного файла. zlib рекомендует, чтобы мы каким-либо образом передали процедуре декомпрессии размер исходных распакованных данных, поэтому мы закодировали размер распакованного бинарного файла в заголовке упакованных данных. Итак, мы получаем указатель на распакованные данные, создаём новый буфер, способный вместить распакованные данные, и приступаем к вызову функции декомпрессии 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;
Как видите, `get_image` в конечном счёте оказалась довольно простой функцией. Мы извлекли целевой бинарный файл из добавленного раздела, так что теперь нам нужно его загрузить.
### Загрузка нашего PE для выполнения
Загрузчик исполняемых файлов Windows делает под капотом много разных вещей и поддерживает множество различных конфигураций исполняемых файлов. Вы, скорее всего, столкнётесь с самыми разными ошибками в упаковщике, который создаётся в этом руководстве, если будете исследовать не только пример бинарного файла, поскольку конфигурация, которую мы будем собирать сегодня, технически очень минимальна. Но чтобы установить этот абсолютный минимум для выполнения, нам нужно сделать следующее для загрузки современного исполняемого файла Windows:
* выделить образ, который содержит представление исполняемого образа в памяти
* отобразить разделы нашего исполняемого образа, включая заголовки, в этот выделенный образ
* разрешить импорты времени выполнения для других библиотек, которые нужны нашему бинарному файлу
* переотобразить бинарный образ так, чтобы различные адреса в нашем образе указывали туда, куда должны
Начнём с функции `load_image`. Для начала нам нужно получить таблицу разделов из упакованного бинарного файла. В конечном счёте она будет отображена на наш недавно выделенный образ. Для правильного буфера исполняемого файла выделение очень простое-- возьмите значение `SizeOfImage` из наших заголовков разделов и создайте новый буфер с помощью [`VirtualAlloc`](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualalloc), который создаёт читаемый, записываемый и исполняемый образ, на который мы можем отобразить данные.```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);
}
Теперь, когда наш буфер выделен, нам нужно скопировать в него заголовки и секции. Это должно соответствовать переменной SectionAlignment из предыдущего шага. К счастью, секции — и наши подготовленные, и остальные — уже выровнены по границе SectionAlignment. Короче говоря, итог сводится к простой арифметике указателей: копируем целевой образ по смещению PointerToRawData в загруженный образ по смещению VirtualAddress.
При желании можно скопировать PE-заголовки в начало образа. Сохранение исходных заголовков упрощает разработку, но отказ от них — хороший шаг к созданию пакера, устойчивого к анализу. Здесь мы копируем заголовки для простоты использования.```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;
Итак, лёгкая часть процесса загрузки позади: мы извлекли наш бинарник из памяти, распаковали его и переотобразили его секции в исполняемую область памяти. Когда наш образ готов, можно углубиться в тонкости процесса загрузки.
### Разрешение API-импортов
В дополнительных заголовках находится то, что называется *каталогом данных*. Этот каталог содержит множество разной информации об исполняемом файле, такой как символы, экспортируемые образом, и ресурсы, например значки и растровые изображения. В этом руководстве мы будем разбирать два каталога данных: **каталог импорта** и **каталог перемещений**. Каждый каталог жёстко задан, и их индексы можно найти [в документации по дополнительному заголовку](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header32) (прокрутите вниз до описания `DataDirectory`). Каталог данных присутствует, если его значение `VirtualAddress` не равно нулю.```c
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
Мы получаем указатели на каталоги данных, выполняя приведение предоставленного RVA. Например, так вы в конечном итоге получаете таблицу импорта из каталога данных импорта:```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);
Чтобы разрешить импорты API, загрузчик разбирает этот каталог, а затем переходит к загрузке необходимых библиотек и получению их импортированных функций. К счастью, этот каталог относительно легко разобрать.
Он начинается со структуры дескриптора импорта:```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;
Больше всего нас беспокоят две переменные с несколько запутанными названиями: OriginalFirstThunk и FirstThunk. OriginalFirstThunk содержит информацию об импортах, ожидаемых этим исполняемым файлом, в том, что касается DLL, заданной RVA Name. В довершение путаницы, то же самое делает и FirstThunk. Чем они различаются? FirstThunk содержит импорты после того, как они были разрешены. Это разрешение выполняется пресловутой функцией GetProcAddress.
Наши thunk-структуры — это дополнительные структуры данных, с которыми приходится иметь дело:```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;
Эта структура данных охватывает как импортируемые, так и экспортируемые thunks, поэтому `ForwarderString` можно игнорировать. Импортный thunk может представлять собой либо `Ordinal`, либо RVA на другую структуру, `IMAGE_IMPORT_BY_NAME`. *Ordinal* — это просто смещение в таблице экспорта данной DLL. Структура `IMAGE_IMPORT_BY_NAME` выглядит следующим образом:```c
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
Это пример вариадической структуры. Она использует отсутствие проверки границ при доступе к массиву для создания структур, размер которых может варьироваться. В данном случае Name ожидается как C-строка, завершающаяся нулевым символом.
Поскольку двоичные данные не содержат типов, порядковый номер и импорт различаются по старшему биту в записи thunk. Порядковый номер содержится в младшей половине целого числа как в 32-битных, так и в 64-битных реализациях.
Наша таблица импорта работает как C-строка — её последняя запись — это завершённый нулём OriginalFirstThunk, обозначающий конец возможных импортов. Наши данные thunk работают аналогично, завершаясь нулевой записью в массиве thunk.
Собирая всё вместе, вот как мы подходим к разбору таблицы импорта в псевдокоде:``` 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
Интересно, что, несмотря на то, что `GetProcAddress` имеет тип C-строки в качестве аргумента функции, Windows ожидает, что вы будете импортировать по порядковому номеру, просто приведя значение порядкового номера к C-строке. Вот так.
С учётом этих объяснений этот цикл `while` теперь должен быть понятен:```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;
}
Теперь, когда импорты разрешены, мы можем перейти к финальной части: работе с каталогом релокаций!
Следующий каталог данных, с которым нам предстоит разобраться, называется каталогом релокаций. Этот каталог отвечает за преобразование абсолютных адресов внутри кода в их новые базовые значения. По сути, этот процесс реализует то, что вам может быть знакомо как рандомизация разрядности адресного пространства, но он также отвечает за то, чтобы адресные пространства DLL не пересекались друг с другом.
Во-первых, нам нужно убедиться, что наш бинарный файл действительно способен перемещать базовые адреса. Иногда, особенно для старых бинарных файлов, эта возможность не включена. В характеристиках конкретного исполняемого файла Windows есть свои характеристики, которые, как ни странно, называются DllCharacteristics в необязательном заголовке. Нас интересует характеристика «dynamic base». Существует особый способ распаковки бинарных файлов без ASLR, который мы не рассматриваем, поэтому, если наш бинарный файл не поддерживает эту возможность, это ошибка. (Это лучше оставить как ошибку в вашем упаковщике, а не в заглушке, но ради удобства изложения материала о PE-заголовках это было помещено сюда.)```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); }
Далее нам нужно вычислить *дельту адреса*. Это просто разница между переменной `ImageBase` образа и базовым адресом виртуального образа. Она используется для быстрой корректировки значений жестко прописанных адресов в бинарном файле.```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;
Теперь мы готовы разобраться с таблицей перемещений.```c typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; DWORD SizeOfBlock; // WORD TypeOffset[1]; } IMAGE_BASE_RELOCATION;
Обратите внимание на закомментированный массив `TypeOffset` — он здесь действительно важен! Таблица перемещений состоит из блоков смещений, содержащих адреса для корректировки и идентифицируемых по RVA `VirtualAddress`. Массив `TypeOffset` содержит закодированные слова, в которых хранится как тип перемещения, так и смещение от `VirtualAddress`, подлежащее корректировке. Что касается типов перемещений, для 64-битных двоичных файлов нас интересует только один тип. К сожалению, эта структура на самом деле не является структурой переменного размера, поэтому для получения массива `TypeOffset` приходится выполнять некоторые арифметические операции с указателями.
Как уже упоминалось, массив `TypeOffset` содержит закодированные слова. Старшие 4 бита (маска `0xF000`) содержат тип перемещения, который можно найти в [разделе «base relocation types»](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) документации по формату PE. Младшие 12 бит (маска `0x0FFF`) содержат смещение от аргумента `VirtualAddress`, подлежащее корректировке.
Объяснять это — утомительное занятие, и в итоге всё выглядит очень запутанно. Получение указателя на адрес для перемещения выглядит так:```cpp
auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);
И настройка этого адреса выглядит так:```cpp *ptr += delta;
Итак, как бы сложно ни было объяснять каталог перемещений, операции в коде на самом деле очень просты для понимания.
Переход к следующему блоку данных перемещений упрощается переменной `SizeOfBlock`. Этот блок содержит размер нашего заголовка, *а также* размер массива `TypeOffset`. Если бы `TypeOffset` вместо этого был массивом фиксированного размера, мы бы просто переходили к следующей записи перемещений, прибавляя вызов `sizeof` заголовка перемещений.
После всех этих объяснений вы сможете понять этот код перемещений:```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
);
}
Поздравляем! Наш образ готов к запуску! Мы уже многое сделали:
Теперь мы готовы запустить наш распакованный бинарный файл!
Вернёмся теперь к нашему главному циклу:```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; }
Как видите, передача выполнения очень проста, хотя и требует знания [указателей на функции](https://en.wikipedia.org/wiki/Function_pointer). Вот наши соответствующие фрагменты:```cpp
// acquire and call the entrypoint
auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint;
reinterpret_cast<void(*)()>(entrypoint)();
AddressOfEntryPoint — это, как вы можете догадаться, RVA точки входа в наш загруженный образ. Вопреки тому, что вы можете знать о точках входа main, исходная точка входа данной двоичной программы не типизирована — в основном ваш C++ компилятор отвечает за настройку окружения кода для передачи аргументов ожидаемым вами функциям main, будь то main или WinMain.
В основе своей объявление указателя на функцию выглядит так:```c return_type (*variable_name)(int arg1, int arg2, ...)
Следовательно, наш указатель на функцию для вызова нашей точки входа-- не имеющий ассоциированного с ней типа-- выглядит так:```c
void (*entrypoint)()
Как приведение типов, это сводится к следующему:```c void(*)()
Собирая всё вместе, мы можем просто привести нашу точку входа к указателю на функцию и вызвать её в той же строке, вот так:```cpp
reinterpret_cast<void(*)()>(entrypoint)();
Если всё загружено правильно, вы должны увидеть, как запускается ваша упакованная программа. В нашем случае, поскольку мы упаковали наш тестовый исполняемый файл, он просто выводит сообщение:``` $ ./packed.exe I'm just a little guy!
Поздравляем! Вы закончили! Вы только что написали Windows-паковщик!
## Дальнейшие упражнения
* **Атакуйте аналитика**: Научитесь реализовывать [некоторые анти-отладочные техники](https://anti-reversing.com/Downloads/Anti-Reversing/The_Ultimate_Anti-Reversing_Reference.pdf) и укрепите свой упаковщик.
* **Расширьте свою поддержку**: Научитесь реализовывать неперемещаемый бинарный файл или научитесь реализовывать дополнительные директории, такие как директория локального хранилища потоков (`IMAGE_TLS_DIRECTORY`) и директория ресурсов (`IMAGE_RESOURCE_DIRECTORY`). Поскольку на этом уровне не хватает документации, вы можете посмотреть мои реализации в [exe-rs](https://github.com/exe-rs).
* **Запутывайте свою заглушку**: Попробуйте выяснить, как аналитик собирается распаковывать ваш бинарный файл, и предотвратите лёгкость распаковки, например [стиранием заголовков](https://github.com/frank2/packer-tutorial/blob/main/stub/src/main.cpp#L84).