
Um tutorial sobre como escrever um packer para Windows!

Parece uma leitura assustadora? Experimente a versão em apresentação, que resume este readme. Vídeo no YouTube em breve!
Um empacotador é um programa que descomprime e lança outro programa dentro do seu espaço de endereçamento (ou, às vezes, no espaço de endereçamento de outro processo). Ele é por vezes conhecido por ser o vetor que ataca ambientes de análise, como depuradores e sandboxes virtuais. É usado principalmente para algumas coisas:
Fundamentalmente, um empacotador tem apenas alguns passos básicos:
Igualmente simples, um empacotador consiste apenas em algumas peças:
Construir um empacotador pode ser complicado, já que o stub precisa ser construído e importado de alguma forma para o executável do empacotador. No Windows, aprender o sistema de build do Visual Studio além da simples compilação pode ser uma tarefa trabalhosa. Felizmente, o CMake fornece um sistema de build simples, multiplataforma, que suporta o Visual Studio e é altamente customizável!
Este tutorial tem como objetivo ensinar o seguinte:
Se você já está familiarizado com C++ e CMake, sinta-se à vontade para pular direto para a seção de empacotamento. Caso contrário, continue lendo!
Primeiro, vamos provar, de alguma forma, que este sistema de build funciona e cria um executável corretamente empacotado. Depois de instalar o CMake e o Visual Studio, navegue até o diretório raiz deste repositório com o terminal de sua preferência e execute o seguinte:``` $ mkdir build $ cd build $ cmake ../
Isso criará os arquivos de projeto necessários para compilar o código do tutorial do Packer. Em seguida, execute:```
$ cmake --build ./ --config Release
Isso compilará o projeto packer no modo Release. Em seguida, você pode executar o seguinte teste:```
$ ctest -C Release ./
Se tudo correr bem, test\_pack e test_unpack devem ser concluídos com êxito. Se você quiser ver os resultados do empacotamento por si mesmo, deverá ver isto:```
$ ./packed.exe
I'm just a little guy!
Vamos falar sobre todas as partes móveis que tornaram isso possível.
Então sabemos quais são os principais componentes de um empacotador, mas como podemos testar nosso empacotador imediatamente como parte do ciclo de desenvolvimento? Precisaremos adicionar um terceiro binário ao nosso projeto geral para testar adequadamente o processo de empacotamento e desempacotamento do nosso binário. No geral, precisamos de três projetos:
Também precisamos de uma biblioteca de compressão para garantir que o binário seja comprimido dentro do executável stub. zlib funcionará bem para isso.
Nossa hierarquia de projetos, para começar, deve ficar assim:``` packer/ +---+ CMakeLists.txt + dummy/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + stub/ | | | +---+ CMakeLists.txt | + src/ | | | +---+ main.cpp | + src/ | | | +---+ main.cpp | + zlib-1.2.13/ | +---+ CMakeLists.txt + ...
Nosso main.cpp em cada pasta pode simplesmente ficar assim por enquanto:```cpp
#include <iostream>
int main(int argc, char *argv[]) {
std::cout << "I'm just a little guy!" << std::endl;
return 0;
}
Já devemos estabelecer que temos uma cadeia de dependências simples para lidar, que o CMake resolverá muito bem para nós com alguma instrumentação:
Vamos começar com o projeto raiz, o packer, para a nossa instrumentação do CMake.
O CMake normalmente exige uma versão mínima para lidar, já que existe há muito tempo e suporta uso de longo prazo de versões anteriores. Depois disso, podemos declarar nosso projeto packer como um projeto C++.```cmake
cmake_minimum_required(VERSION 3.24)
project(packer CXX)
Também queremos declarar nosso empacotador como "MultiThreaded" (ou seja, /MT) em vez de "MultiThreadedDLL" (ou seja, /MD) para não termos que nos preocupar com dependências de DLL em tempo de execução.```cmake
# this line will mark our packer as MultiThreaded, instead of MultiThreadedDLL
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
A declaração entre colchetes angulares na declaração de definição de variável é chamada de expressão de gerador e nos ajuda a resolver dados de tempo de configuração quando necessário; você a verá bastante ao longo deste arquivo. O que essa expressão de gerador faz é emitir a string Debug quando detecta que a configuração é Debug, e não emite nada caso contrário. Isso fornece uma biblioteca de tempo de execução de MultiThreadedDebug quando um perfil de compilação Debug é selecionado, e MultiThreaded quando um perfil de compilação sem depuração é selecionado, como Release. Consulte expressões de gerador condicionais para entender bem o que está acontecendo aí. Para mais informações sobre a variável CMAKE_MSVC_RUNTIME_LIBRARY, veja a documentação do CMake.
O CMake permitirá que você organize seu código-fonte em hierarquias como o Visual Studio faz automaticamente ao criar projetos com sua interface, e também fará uma busca recursiva em suas pastas por nomes de arquivos correspondentes. Em nosso arquivo de configuração, definimos uma recursão global em cabeçalhos (.hpp), código (.cpp) e scripts de recurso (.rc). Para nosso exemplo, só precisamos mesmo de main.cpp, mas isso é útil saber para projetos maiores.```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})
Aqui devo explicar que o CMake é mais precisamente descrito como um **sistema de make**. Ele cria o "sistema de make" adequado para o ambiente fornecido com base no compilador fornecido. No Linux, isso seria um makefile para os compiladores detectados (ou fornecidos). Para nós no Windows, isso produz um projeto do Visual Studio compatível com a nossa versão do Visual Studio, o que significa que, uma vez que você crie o sistema de make do MSVC, você pode simplesmente usar o Visual Studio para tudo, se assim desejar! Em última análise, você está usando o CMake para configurar o Visual Studio. Então, o que você está fazendo aqui é essencialmente criar as árvores de arquivos do seu projeto no Visual Studio que a GUI faz para você ao criar um projeto.
Em seguida, precisamos adicionar nossos projetos dependentes:```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)
Uma coisa que mencionei antes é que o projeto do packer depende do projeto do stub. O packer, de alguma forma, precisa reter o stub e manipulá-lo para eventualmente obter nosso executável empacotado. Podemos usar arquivos de recursos do Windows para eventualmente colocar nosso executável stub dentro do binário do packer, independentemente da configuração de build! Além disso, podemos usar o CMake para gerar esses arquivos para nós, de modo que nossas referências aos executáveis gerados sejam consistentes dentro do projeto CMake! Vamos gerar nossos arquivos de recursos por enquanto para podermos incluí-los em nosso projeto:```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` é a string que contém o seu diretório de build atual. O Visual Studio despeja os binários em pastas com base na sua configuração, então usamos a instrução de gerador `$<CONFIG>` para obter a configuração atual do build. Também usamos a instrução de gerador `$<TARGET_FILE:stub>` para gerar o nome do arquivo executável do binário stub depois que ele for compilado. Quando incluímos esses arquivos em nosso projeto -- o arquivo RC gerado e o cabeçalho gerado -- podemos integrar com sucesso nosso binário stub ao projeto packer.
Em seguida, declaramos um executável para nosso projeto packer com os arquivos coletados 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")
Em seguida, vinculamos a biblioteca zlib importada para o linker:```cmake
target_link_libraries(packer zlibstatic)
Em vez disso, você pode vincular apenas `zlib` se realmente quiser a DLL do zlib.
Como temos arquivos gerados (e o zlib também, como parte de sua etapa de build), precisamos incluir os diretórios dinâmicos nos cabeçalhos de inclusão do projeto. Devido a uma dependência de os projetos estarem na raiz de um projeto no CMake, também adicionamos os diretórios de inclusão do zlib para o nosso binário stub:```cmake
# zlib, as part of its build step, drops a config header in the build directory.
# we do this too, so make sure to include everything for the build!
target_include_directories(packer PUBLIC
"${PROJECT_SOURCE_DIR}/src"
"${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>"
"${PROJECT_SOURCE_DIR}/zlib-1.2.13"
"${CMAKE_CURRENT_BINARY_DIR}/zlib-1.2.13"
)
# also set the includes for the stub from here.
# we can't set this in the stub CMake file because CMake requires includes to be in the same
# directory as the build target. for this file, our build target is packer, so this sets
# up includes relative to the packer executable.
target_include_directories(stub PUBLIC
"${PROJECT_SOURCE_DIR}/zlib-1.2.13"
"${CMAKE_CURRENT_BINARY_DIR}/zlib-1.2.13"
)
Por fim, resolvemos as coisas em nossa cadeia de dependências informando ao CMake sobre a nossa situação de dependências: marque o stub como uma dependência do packer e o packer como uma dependência do dummy.```cmake
add_dependencies(packer stub) add_dependencies(dummy packer)
Os arquivos CMake para [o stub](https://github.com/frank2/packer-tutorial/blob/main/stub/CMakeLists.txt) e [o dummy](https://github.com/frank2/packer-tutorial/blob/main/dummy/CMakeLists.txt) devem ser bem fáceis de entender agora!
O melhor é que o CMake pode gerenciar os testes por nós! E neste ponto, gerar testes para o nosso empacotador é um processo tranquilo: podemos simplesmente emitir um comando, e se o código de saída for 0, o teste passa. Para simplificar, digamos que empacotamos um binário passando-o como primeiro argumento do executável. Queremos algo assim:```
$ packer.exe dummy.exe
Para fazer isso com o CMake é muito simples:```cmake
enable_testing() add_test(NAME test_pack COMMAND "$<TARGET_FILE:packer>" "$<TARGET_FILE:dummy>")
Finalmente, testar se o programa descompacta ou não é ainda mais simples: basta executar a saída! Não deveria ser surpreendente quantos erros você encontrará que travam o programa simplesmente ao executá-lo quando você está tentando emular o loader! Digamos novamente, por uma questão de simplicidade, que o binário deve gerar a saída para "packed.exe" para um binário compactado. Nesse caso, tudo o que você precisa fazer é isto:```cmake
add_test(NAME test_unpack
COMMAND "packed.exe")
Isto falhará graciosamente se o seu packer não tiver gerado o binário. Infelizmente, o CMake para Visual Studio ignora a variável ADDITIONAL_CLEAN_FILES, então terá de limpar manualmente todos os ficheiros gerados no seu sistema de build, incluindo os do stub.rc e stub.hpp gerados acima.
Parabéns! Muito trabalho foi feito para compilar e testar com sucesso o seu packer. Agora que comemos os vegetais, o nosso packer pode fazer o seguinte:
Agora podemos avançar para o que interessa!
Então, configurámos com sucesso o nosso compilador para compilar o executável do stub como um recurso no nosso binário do packer, mas como conseguimos colocar um binário num estado empacotado no stub? Não podemos adicioná-lo como recurso porque não podemos esperar que o utilizador final do packer use apenas um compilador; o executável do packer deve ser uma solução autónoma.
Uma técnica que usei muito (embora possa ser óbvia para análise) é adicionar uma nova secção ao binário do stub para eventualmente ser carregada em tempo de execução. Isto acabará por servir também como um curso intensivo sobre o formato PE. Mas primeiro, como extraímos os dados dos recursos?
Existem três passos básicos para obter dados de recursos de um binário em tempo de execução:
A seguinte função demonstra como procurar e adquirir um recurso de um determinado binário:```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]); }
Com nossos dados em um vetor facilmente maleável, agora podemos analisar a imagem stub e adicionar novos dados a ela.
### Parsing a PE file
Um executável Windows, em seus componentes mais básicos, é dividido em duas partes: seus *cabeçalhos* e seus *dados de seção*. Os cabeçalhos contêm muitos metadados importantes para o processo de carregamento, e os dados de seção são apenas isso-- dados, que podem ser código executável (ou seja, a seção `.text`) ou dados arbitrários (ou seja, uma seção `.data`). O início de todo executável Windows começa com uma estrutura `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;
Embora essa estrutura pareça ter muita coisa acontecendo, você provavelmente pode deduzir pelo nome que esse cabeçalho é um resquício de versões passadas do Windows e do Microsoft DOS. Aqui, só nos preocupamos com dois valores nesse cabeçalho: e_magic e e_lfanew. e_magic é simplesmente o valor do cabeçalho mágico no topo da imagem, o "MZ" no início do arquivo. e_lfanew é o deslocamento para os cabeçalhos NT a partir do início do arquivo, que são os cabeçalhos PE contendo muito mais informações de metadados sobre o executável. Podemos, por exemplo, construir um validador PE simples para o nosso empacotador assim:```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` é uma estrutura bastante grande no geral, então não vou documentar tudo aqui, mas você pode encontrar tudo o que precisa saber sobre ela na [documentação da Microsoft sobre o cabeçalho](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_nt_headers64). De qualquer forma, só precisaremos de alguns membros da estrutura desses cabeçalhos. Por enquanto, devemos comprimir nosso binário alvo para adicioná-lo aos dados do nosso stub.
Usar as funções `compress` e `decompress` do zlib é bastante direto. Se você realmente quiser se aprofundar no trabalho com o zlib, sugiro usar as funções `deflate`/`inflate`, que permitem trabalhar em fluxos de compressão em blocos por vez. Veja a seção "Advanced Functions" do [manual do zlib](https://www.zlib.net/manual.html). Para este exemplo, porém, `compress` e `decompress` serão suficientes.
Para começar, obtemos um valor de tamanho com a função `compressBound` do zlib com base no tamanho do nosso binário alvo. Esse valor de tamanho corresponde ao valor máximo necessário para armazenar um fluxo de dados compactado, dado o tamanho dos dados. Podemos então usar esse valor para alocar um vetor que conterá os dados compactados. A função `compress` eventualmente retorna o tamanho real do buffer compactado, para o qual podemos redimensionar nosso vetor para o tamanho adequado.```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);
Vamos dedicar um momento para falar sobre alinhamento de dados. Um determinado fluxo de dados é considerado alinhado se o seu endereço ou tamanho for divisível por um determinado limite de alinhamento. Por exemplo, em arquivos PE, as seções de dados em disco são tipicamente alinhadas ao limite de 0x400, enquanto em memória são alinhadas ao limite de 0x1000. Podemos determinar se um determinado valor está alinhado realizando um módulo do alinhamento sobre o valor (ou seja, valor % alinhamento == 0). Arquivos PE podem ser arbitrariamente alinhados a outros valores, e esse valor está presente e é importante para o carregador de PE como um todo. Alinhar um determinado valor a um determinado limite é uma operação relativamente simples:```cpp
template
T align(T value, T alignment) {
auto result = value + ((value % alignment == 0) ? 0 : alignment - (value % alignment));
return result;
}
Esta função essencialmente preenche um valor potencialmente desalinhado com o restante necessário para ser devidamente alinhado a um determinado limite.
Para adicionar corretamente dados arbitrários ao nosso arquivo PE, precisamos estar atentos, em particular, ao *alinhamento de arquivo* — podemos calcular os valores alinhados adequados para o arquivo PE quando ele estiver em memória mais tarde, mas por enquanto, ao adicionar nossos dados, precisamos alinhar nosso arquivo ao limite de alinhamento de arquivo. No código a seguir, obtemos os cabeçalhos, depois os limites de alinhamento de arquivo e de alinhamento de seção, e então prosseguimos para alinhar nossos dados de stub ao limite do arquivo e anexar nossa seção recém-empacotada.```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());
Agora, podemos ter adicionado os dados da nossa seção de acordo com os limites das seções do arquivo, mas o nosso executável stub ainda não tem conhecimento dessa seção no arquivo PE. Precisamos não apenas analisar a tabela de seções do arquivo PE, mas também adicionar uma nova entrada que aponte para a nossa seção. Esse é o motivo da variável raw_offset.
Primeiro, podemos incrementar o número de seções facilmente atualizando o NumberOfSections. Normalmente, os dados após a última tabela de seções são zerados, então podemos sobrescrever os dados zerados com a nossa nova seção facilmente.```cpp
// increment the number of sections in the file header
auto section_index = nt_header->FileHeader.NumberOfSections;
++nt_header->FileHeader.NumberOfSections;
Em seguida, precisamos obter um ponteiro para a própria tabela de seções. Embora, tecnicamente, ela venha imediatamente após o cabeçalho opcional dos cabeçalhos NT, o tamanho do cabeçalho opcional é, na verdade, determinado pelo valor `SizeOfOptionalHeader` do cabeçalho de arquivo NT. Então, para chegar até ela, precisamos calcular um ponteiro do topo da struct `OptionalHeader` até o deslocamento fornecido pelo 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 prontos para começar a adicionar os metadados da nossa seção. Este é o nosso PE cabeçalho de seção:```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;
As variáveis específicas que nos interessam para o nosso novo cabeçalho de seção são `Name`, `VirtualSize`, `VirtualAddress`, `SizeOfRawData`, `PointerToRawData` e `Characteristics`. Neste ponto, você deve estar ciente de que a razão pela qual você precisa conhecer dois tipos de alinhamento-- alinhamento de arquivo e alinhamento de memória-- é porque existem dois tipos diferentes de estados de memória para um determinado executável PE: como ele se parece no *disco*, e como ele se parece na *memória*, como resultado do processo de carregamento. É possível configurar um determinado arquivo PE para ter o mesmo layout de memória independentemente de ser carregado ou não, mas não é uma configuração frequente.
A variável `Name` é o rótulo de 8 bytes que você pode dar à sua nova seção. Escolhi `.packed`, pois é uma string ASCII de 7 bytes e cabe perfeitamente no buffer.
`VirtualAddress` refere-se ao deslocamento de uma determinada seção na memória. Também é conhecido como "endereço virtual relativo", ou RVA. `VirtualSize` refere-se ao tamanho da seção na memória. (Vale notar que o MSVC compila esse valor como o valor de tamanho não alinhado da seção, então seguimos essa convenção também em nossa seção.) `PointerToRawData` refere-se ao deslocamento de uma determinada seção no disco, e `SizeOfRawData` refere-se ao tamanho da seção no disco.
`Characteristics` é complicado e pode se referir a uma seção ser legível, gravável ou executável, além de outros indicadores; veja a seção de características da [documentação de `IMAGE_SECTION_HEADER`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_section_header). Por enquanto, você deve saber que tudo o que precisamos é que a seção seja legível e marcada como contendo dados inicializados.
Com tudo isso em mente, agora podemos criar nossa nova seção!```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;
Isso agora apresenta um pequeno problema, porém: o tamanho da imagem mudou. Você pensaria que não seria um problema, mas no cabeçalho opcional do cabeçalho NT há uma variável chamada SizeOfImage que determina quanto espaço o loader precisa alocar para nosso executável. Isso é uma correção simples, no entanto: o tamanho que precisamos para nossa imagem é o tamanho da última seção alinhada ao limite de alinhamento da seção.```cpp
// calculate the new size of the image.
nt_header->OptionalHeader.SizeOfImage = align(virtual_offset + virtual_size, section_alignment);
E é isso! Adicionamos com sucesso nosso binário compactado como uma nova seção para o nosso stub eventualmente descomprimir e carregar. Agora podemos simplesmente salvar a imagem do stub modificada em 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();
Parabéns! Até agora, conseguimos realizar o seguinte:
Estamos na metade do caminho na escrita do nosso empacotador! Agora podemos avançar para a parte indiscutivelmente mais difícil do processo: dar corpo ao binário stub.
Embora os detalhes de escrever um stub de desempacotamento possam ficar complicados internamente, no fundo tudo se resume a apenas alguns passos:
Esse processo é tão simples que nossa rotina principal é apenas um punhado de funções:```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; }
### Lendo nosso PE da memória
Primeiro, precisamos de alguma forma obter os dados da nossa seção criada pelo packer do binário em execução. É possível obter os cabeçalhos do binário em execução em tempo de execução? Sim, absolutamente! [`GetModuleHandleA`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlea), com um argumento nulo, em última análise, retorna um ponteiro para os cabeçalhos do nosso PE em execução! Então, em tempo de execução, você tem acesso fácil à imagem como ela existe na memória. Isso é o que torna adicionar uma nova seção ao nosso binário tão atraente: podemos analisar muito facilmente nossa seção alvo do binário.
Com o que aprendemos sobre a análise da tabela de seções, esta seção de código deve 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);
}
Em seguida, precisamos descomprimir os dados do nosso stub a partir do binário. O zlib recomenda que codifiquemos o tamanho da carga útil original descomprimida de alguma forma acessível à rotina de descompressão, por isso codificamos o tamanho do binário descomprimido no cabeçalho dos nossos dados empacotados. Então, obtemos um ponteiro para os nossos dados descomprimidos, criamos um novo buffer que possa armazenar os dados descomprimidos e prosseguimos para chamar a função de descompressão do 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 você pode ver, `get_image` era uma função relativamente simples no final das contas. Extraímos nosso binário alvo da seção anexada, então agora precisamos carregá-lo.
### Carregando nosso PE para execução
O carregador de executáveis do Windows faz muitas coisas diferentes nos bastidores e suporta muitas configurações diferentes de executáveis. É provável que você encontre uma variedade de erros com o empacotador que este tutorial produz se explorar mais do que o binário de exemplo, pois a configuração que construiremos hoje é tecnicamente muito mínima. Mas, para estabelecer esse mínimo absoluto para execução, precisamos fazer as seguintes coisas para carregar um executável Windows moderno:
* alocar a imagem que contém a representação em memória da imagem do executável
* mapear as seções da nossa imagem executável, incluindo os cabeçalhos, para essa imagem alocada
* resolver as importações em tempo de execução para outras bibliotecas que nosso binário precisa
* remapear a imagem do binário para que vários endereços na nossa imagem apontem para onde deveriam
Vamos começar com a função `load_image`. Para começar, precisamos obter nossa tabela de seções do binário empacotado. Isso eventualmente será mapeado na nossa imagem recém-alocada. Para um buffer de executável adequado, a alocação é muito simples: pegue o valor `SizeOfImage` dos nossos cabeçalhos de seção e crie um novo buffer com [`VirtualAlloc`](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualalloc) que crie uma imagem legível, gravável e executável para mapearmos.```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);
}
Com o nosso buffer alocado, o que precisamos fazer é copiar os cabeçalhos e as seções para ele. Isso precisa estar em conformidade com a variável SectionAlignment mencionada anteriormente. Felizmente, a maneira como preparamos a nossa seção-- e a maneira como as outras seções são preparadas-- elas já estão alinhadas ao limite do SectionAlignment. Basta dizer que o resultado final é uma simples aritmética de ponteiros: copiar a nossa imagem alvo no deslocamento PointerToRawData, para a nossa imagem carregada no deslocamento VirtualAddress.
Opcionalmente, você pode copiar os cabeçalhos do PE para o topo da imagem. Manter os cabeçalhos originais facilita o desenvolvimento, mas querer removê-los é um bom passo em direção à construção de um empacotador hostil à análise. Copiamos os cabeçalhos aqui para facilitar o 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;
Então a parte fácil do processo de carregamento acabou: recuperamos nosso binário da memória, desempacotamos e remapeamos suas seções para uma região de memória executável. Com nossa imagem preparada, podemos mergulhar nos detalhes minuciosos do processo de carregamento.
### Resolvendo importações de API
Dentro dos cabeçalhos opcionais há algo chamado *diretório de dados*. Esse diretório contém muitas informações diferentes sobre o executável, como símbolos exportados pela imagem e recursos como ícones e bitmaps. Neste tutorial, vamos analisar dois diretórios de dados: o **diretório de importação** e o **diretório de realocação**. Cada diretório é fixo (hardcoded), e seus índices podem ser encontrados [na documentação do cabeçalho opcional](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header32) (role para baixo até a descrição de `DataDirectory`). Um diretório de dados está presente se o valor de `VirtualAddress` for não nulo.```c
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
Recuperamos ponteiros para os diretórios de dados através do casting do RVA fornecido. Por exemplo, é assim que você eventualmente obtém a tabela de importação a partir do diretório de dados de importação:```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 importações de API, o carregador analisa este diretório e, em seguida, carrega as bibliotecas necessárias e adquire as suas funções importadas. Felizmente, é um diretório relativamente fácil de analisar.
Começa com uma estrutura de descritor de importação:```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;
O que mais nos preocupa são duas variáveis com nomes um tanto confusos: OriginalFirstThunk e FirstThunk. OriginalFirstThunk contém informações sobre as importações desejadas por este executável em relação à DLL fornecida pelo RVA Name. Para aumentar a confusão, FirstThunk também contém. O que as diferencia? FirstThunk contém as importações depois de resolvidas. Essa resolução é realizada pela infame função GetProcAddress.
Nossos thunks são estruturas de dados adicionais com as quais precisamos lidar:```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 estrutura de dados cobre tanto thunks de importação quanto de exportação, portanto `ForwarderString` pode ser ignorado. Um thunk de importação pode ser um `Ordinal` ou um RVA para outra estrutura, `IMAGE_IMPORT_BY_NAME`. Um *ordinal* é simplesmente um deslocamento na tabela de exportação da DLL fornecida. Uma estrutura `IMAGE_IMPORT_BY_NAME` tem esta aparência:```c
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
Isto é um exemplo de uma estrutura variádica. Ela tira vantagem da falta de verificação de limites no acesso a arrays para criar estruturas que podem ter tamanho variável. Name, neste caso, deve ser uma C-string terminada em zero.
Como dados binários não contêm tipos, um ordinal e um import são diferenciados pelo bit mais significativo na entrada de thunk. O ordinal está contido na metade inferior do inteiro em implementações de 32 bits e de 64 bits.
Nossa tabela de importação funciona como uma C-string-- sua última entrada é um OriginalFirstThunk terminado em nulo para sinalizar o fim das importações potenciais. Nossos dados de thunk funcionam da mesma forma, terminando com uma entrada nula no array de thunks.
Para juntar tudo, é assim que abordamos a análise da tabela de importação em 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, apesar de `GetProcAddress` ser tipada com uma C-string como argumento para a função, o Windows espera que você importe por ordinal simplesmente convertendo o valor ordinal como uma C-string. Vai entender.
Com essas explicações em mente, este loop while agora deve fazer 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;
}
Com as importações resolvidas, podemos agora seguir para a parte final: tratar do diretório de realocação!
O próximo diretório de dados a ser enfrentado é o chamado diretório de realocação. Esse diretório é responsável por traduzir endereços absolutos dentro do código para seus novos valores de base. Esse processo essencialmente implementa algo que você pode conhecer, chamado randomização do layout do espaço de endereços, mas também é responsável por garantir que os espaços de endereço de DLLs não colidam entre si.
Primeiro, precisamos garantir que nosso binário seja realmente capaz de mover bases de endereços. Às vezes, especialmente em binários mais antigos, isso não está habilitado. Dentro das características de um determinado executável Windows estão suas características, confusamente chamadas de DllCharacteristics no cabeçalho opcional. Estamos interessados na característica "base dinâmica". Existe uma maneira especial de desempacotar binários sem ASLR, que não abordaremos, então é um erro se nosso binário não oferecer suporte a isso. (Isso é melhor como um erro no seu empacotador, não no seu stub, mas para facilitar o fluxo do aprendizado sobre o cabeçalho PE, foi colocado aqui em vez disso.)```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); }
Em seguida, precisamos calcular o *delta de endereço*. Isso é simplesmente a diferença entre a variável `ImageBase` da imagem e o endereço base da imagem virtual. É usado para ajustar rapidamente os valores de endereços fixos no binário.```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;
Agora estamos prontos para enfrentar a tabela de realocação.```c typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; DWORD SizeOfBlock; // WORD TypeOffset[1]; } IMAGE_BASE_RELOCATION;
Observe o array `TypeOffset` comentado, na verdade ele é relevante aqui! Uma tabela de realocação consiste em blocos de offsets contendo endereços a ajustar, identificados pelo RVA `VirtualAddress`. O array `TypeOffset` contém valores de palavras codificados que contêm o tipo de realocação bem como o offset a partir de `VirtualAddress` a ajustar. No que diz respeito ao tipo de realocação, para binários de 64 bits, estamos preocupados apenas com um tipo de realocação. Infelizmente, essa estrutura não é realmente uma struct variádica, então temos que fazer alguma aritmética de ponteiros para obter o array `TypeOffset`.
Como mencionado, o array `TypeOffset` contém palavras codificadas. Os 4 bits superiores (máscara `0xF000`) contêm o tipo de realocação, que pode ser encontrado na [seção "base relocation types"](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) da documentação do formato PE. Os 12 bits inferiores (máscara `0x0FFF`) contêm o offset a partir do argumento `VirtualAddress` a ajustar.
Explicar isso é uma tarefa árdua e acaba parecendo muito confuso no final. Obter um ponteiro para um endereço a realocar parece com isto:```cpp
auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);
E ajustar esse endereço fica assim:```cpp *ptr += delta;
Por mais complicado que seja explicar o diretório de realocação, as operações são, na verdade, muito simples de entender no código.
Avançar para o próximo bloco de dados de realocação é facilitado pela variável `SizeOfBlock`. Este bloco contém o tamanho do nosso cabeçalho *assim como* o tamanho do array `TypeOffset`. Se `TypeOffset` fosse, em vez disso, um array de tamanho estático, simplesmente avançaríamos para a próxima entrada de realocação adicionando a chamada a `sizeof` do cabeçalho de realocação.
Com tudo isso explicado, você deverá ser capaz de entender este código de realocação:```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
);
}
Parabéns! Nossa imagem agora está pronta para execução! Já realizamos muita coisa até aqui:
Agora estamos prontos para executar nosso binário desempacotado!
Vamos voltar ao nosso loop principal agora:```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 você pode ver, transferir a execução é muito simples, embora exija conhecimento de [ponteiros de função](https://en.wikipedia.org/wiki/Function_pointer). Estas são as nossas partes relevantes:```cpp
// acquire and call the entrypoint
auto entrypoint = loaded_image + nt_headers->OptionalHeader.AddressOfEntryPoint;
reinterpret_cast<void(*)()>(entrypoint)();
AddressOfEntryPoint é, como você pode adivinhar, um RVA no ponto de entrada de código da nossa imagem carregada. Apesar do que você possa saber sobre entrypoints main, o entrypoint bruto de um binário específico não é tipado— seu compilador C++ é o principal responsável por configurar o ambiente de código para alimentar argumentos às suas funções main esperadas, sejam elas main ou WinMain.
Fundamentalmente, é assim que um ponteiro de função é declarado:```c return_type (*variable_name)(int arg1, int arg2, ...)
Portanto, nosso ponteiro de função para chamar nosso ponto de entrada-- sem ter tipo associado a ele-- fica assim:```c
void (*entrypoint)()
Como um cast, isso se reduz a:```c void(*)()
Juntando tudo, podemos simplesmente converter nosso entrypoint em um ponteiro de função e chamá-lo na mesma linha, assim:```cpp
reinterpret_cast<void(*)()>(entrypoint)();
Com tudo carregado corretamente, você deverá ver o seu programa empacotado em execução. No nosso caso, como empacotamos nosso executável fictício, ele simplesmente exibe uma mensagem:``` $ ./packed.exe I'm just a little guy!
Parabéns! Você terminou! Você acabou de escrever um packer para Windows!
## Exercícios adicionais
* **Ataca o analista**: Aprenda a implementar [algumas técnicas de anti-debug](https://anti-reversing.com/Downloads/Anti-Reversing/The_Ultimate_Anti-Reversing_Reference.pdf) e endureça o seu packer.
* **Expanda o seu suporte**: Aprenda a implementar um binário não realocável, ou aprenda a implementar outros diretórios, como o diretório de armazenamento local de thread (`IMAGE_TLS_DIRECTORY`) e o diretório de recursos (`IMAGE_RESOURCE_DIRECTORY`). Como a documentação é escassa neste nível, você pode ver as minhas implementações em [exe-rs](https://github.com/exe-rs).
* **Ofusque o seu stub**: Tente descobrir como o analista vai desempacotar o seu binário e dificulte o desempacotamento, como por exemplo [apagando os cabeçalhos](https://github.com/frank2/packer-tutorial/blob/main/stub/src/main.cpp#L84).