Skip to content
KitploitKITPLOIT
工具博客
提交
工具博客
提交

黑客、渗透测试和网络安全工具,武装您的安全武器库!

Kitploit 是一个黑客、网络安全和渗透测试工具的目录。发现最新的项目更新,查找漏洞、分析系统、自动化测试并加强你的安全。

··订阅源·联系·隐私·© 2026 Kitploit

工具目录

分类

查看所有分类
Loading categories
packer-tutorial — 一份关于如何为Windows编写打包器(packer)的教程! | Kitploit
工具/GitHubGitHub/frank2/packer-tutorial
逆向工程恶意软件分析二进制分析学习与教育
GitHubfrank2/packer-tutorial

packer-tutorial

一份关于如何为Windows编写打包器(packer)的教程!

查看仓库
319322年前Kitploit 审核通过

最受欢迎

查看全部 →

发现我们社区最常用的工具。

探索所有工具

浏览我们的工具集合

查看所有工具 →
分享

PACKERS

目录

  1. 什么是加壳器?:介绍加壳器的用途,并指导了解开发加壳器所需的条件。
  2. 先决条件:学习本教程所需的工具。
  3. 初步体验:演示你将通过本教程构建的内容。
  4. 规划我们的 CMake 项目:一个迷你 CMake 教程,介绍如何为加壳器较为复杂的需求创建构建系统。
  5. 将二进制文件打包进你的存根:关于如何编写加壳器/存根组合中的加壳器部分,同时介绍 Windows 可执行文件格式。
    1. 管理资源
    2. 解析 PE 文件
    3. 操作 PE 文件
  6. 模拟加载器:关于如何构建一个最小的存根可执行文件来解包并加载目标可执行文件,同时介绍更高级的 Windows 可执行文件操作。
    1. 从内存中读取我们的 PE
    2. 加载我们的 PE 以执行
    3. 解析 API 导入
    4. 解析地址
    5. 转移执行
  7. 进一步练习:一些拓展你加壳器开发知识的练习。

看起来令人生畏?试试演示文稿版本,它总结了本 README。YouTube 视频即将推出!

什么是加壳器?

加壳器是一种在其自身地址空间(有时是另一个进程的地址空间)内解压缩并启动另一个程序的程序。它有时以攻击分析环境(如调试器和虚拟沙箱)的载体而闻名。它主要用于以下几件事:

  • 压缩:加壳器通常用于压缩给定二进制文件的代码。这是它为数不多的合法用途之一。参见 UPX 了解压缩加壳器的示例。
  • 混淆:加壳器也用于试图混淆或以其他方式保护程序免遭逆向工程。参见 Riot Games 的 packman 加壳器 了解反逆向加壳器的示例。
  • 规避:恶意软件经常使用各种加壳器来规避防病毒软件甚至 EDR。参见 这篇对 SmokeLoader 加壳器的分析 了解规避型加壳器的示例。

从根本上说,加壳器只有几个基本步骤:

  1. 压缩阶段:这是将原始可执行文件压缩、混淆或两者兼施,形成一个新的二进制文件的阶段。
  2. 解压缩阶段:这是加壳后的可执行文件解压缩或去混淆其原始可执行文件以进行加载的阶段。
  3. 加载阶段:这是加壳器模仿与宿主平台的可执行加载器类似的多种步骤的阶段。由于可执行二进制文件的复杂性,这是最复杂的步骤,具体取决于你希望模拟的深度。
  4. 执行阶段:这是代码从宿主存根可执行文件转移到新加载(即解包后)代码的阶段。

同样简单的是,加壳器只由几个部分组成:

  • 加壳器:这部分负责准备所谓的 存根可执行文件,并为其提供解包给定二进制文件所需的数据。这最终就是压缩阶段。
  • 存根:这个二进制文件是负责解压缩、加载和执行原始二进制文件的代码部分。如你所见,存根可执行文件承担了加壳器的大部分繁重工作。

构建加壳器可能很棘手,因为存根需要以某种方式构建并导入加壳器可执行文件中。对于 Windows 来说,学习 Visual Studio 的构建系统(超越简单编译)可能是一项繁重的任务。幸运的是,CMake 提供了一个简单、跨平台的构建系统,支持 Visual Studio,并且高度可定制!

本教程旨在教授以下内容:

  • 如何将加壳器的两个主要部分分段并集成到一个内聚、可测试的构建环境中。
  • 如何操作 Windows 可执行文件以添加额外的、可加载的数据。
  • 如何浏览 Windows 可执行文件以检索和加载任意数据。
  • 如何模拟 Windows 加载器的部分功能来加载和执行 Windows 可执行文件。

如果你已经熟悉 C++ 和 CMake,可以随意跳过直接进入打包部分。否则,请继续阅读!

先决条件

  • C++ 知识:如果你不懂 C++,本教程就没什么用,因为我们会大量使用指针运算。
  • Visual Studio:Visual Studio 包含一个功能齐全的 Windows C++ 编译器。本项目已在 Visual Studio 2019 上测试,但更新版本应该也没问题。
  • CMake:CMake 是我们用来为 Visual Studio 编译器构建系统提供支持的构建系统。

初步体验

首先,让我们以某种方式证明这个构建系统有效,并且能创建正确的加壳可执行文件。安装好 CMake 和 Visual Studio 后,使用你选择的终端导航到本仓库的根目录,并执行以下命令:``` $ mkdir build $ cd build $ cmake ../

root@kitploit:~
这将创建构建 packer 教程代码所需的项目文件。然后运行:```
$ cmake --build ./ --config Release

这将以 Release 模式构建 packer 项目。然后,你可以运行以下测试:``` $ ctest -C Release ./

root@kitploit:~
如果一切顺利,test\_pack 和 test_unpack 应该会成功。如果你想亲自查看打包的结果,你应该会看到:```
$ ./packed.exe
I'm just a little guy!

让我们来聊聊实现这一切所涉及的所有关键部分。

勾勒我们的 CMake 项目

因此,我们了解了加壳器的主要组件,但如何在开发周期中立即测试我们的加壳器呢?我们需要在整体项目中添加第三个二进制文件,以便正确测试我们二进制的加壳与脱壳过程。总体而言,我们需要三个项目:

  • 加壳器
  • stub(存根)
  • 用于加壳的虚拟可执行文件

我们还需要一个压缩库,以确保二进制文件能够压缩进 stub 可执行文件中。这里使用 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 + ...

root@kitploit:~
每个文件夹中的 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 和 stub 依赖于 zlib
  • packer 依赖于 stub
  • dummy 依赖于 packer(因为它需要由 packer 打包)

让我们从 根项目(即 packer)开始进行我们的 CMake 配置。

CMake 通常要求指定一个最低版本,因为它已经存在很长时间,并支持长期使用旧版本。之后,我们可以将 packer 项目声明为一个 C++ 项目。```cmake

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

cmake_minimum_required(VERSION 3.24)

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

detection might get confused)

project(packer CXX)

root@kitploit:~
我们还希望将我们的打包器声明为"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,否则不输出任何内容。这样,当选择 Debug 编译配置时,会提供 MultiThreadedDebug 运行时库;而当选择非调试编译配置(如 Release)时,则提供 MultiThreaded。请参阅条件生成器表达式以深入理解其中的原理。有关 CMAKE_MSVC_RUNTIME_LIBRARY 变量的更多信息,请参阅 CMake 文档。

CMake 允许你将源代码组织成层级结构,就像 Visual Studio 通过其界面创建项目时自动做的那样;它还会在你的文件夹中递归搜索匹配的文件名。在我们的配置文件中,我们对头文件(.hpp)、代码文件(.cpp)和资源脚本(.rc)设置了全局递归。对于我们的示例,我们实际上只需要 main.cpp,但对于更大的项目,了解这一点会很有用。```cmake

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

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

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

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

root@kitploit:~
这里我应当说明,更准确地说,CMake 是一种 **构建系统**(make system)。它会根据给定的编译器,为当前环境生成相应的“构建系统”。在 Linux 上,这会为检测到(或指定)的编译器生成一个 makefile。而在 Windows 上,它会生成一个与我们所用 Visual Studio 版本兼容的 Visual Studio 工程,这意味着一旦你创建了 MSVC 构建系统,之后就可以直接使用 Visual Studio 完成一切操作——只要你愿意!归根结底,你是在用 CMake 来配置 Visual Studio。因此,你现在所做的,本质上就是在 Visual Studio 中创建项目里的文件树,就像 GUI 在创建项目时替你完成的那样。

接下来,我们需要添加我们所依赖的项目:```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

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

despite where it may reside in cmake's build system

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

root@kitploit:~
`CMAKE_CURRENT_BINARY_DIR` 是包含当前构建目录的字符串。Visual Studio 会根据配置将二进制文件转储到不同文件夹中,因此我们使用 `$<CONFIG>` 生成器表达式来获取当前构建配置。我们还使用 `$<TARGET_FILE:stub>` 生成器表达式来输出存根二进制文件编译后的可执行文件名。当我们将这些文件(生成的 RC 文件和生成的头文件)包含到项目中时,就可以成功地将存根二进制文件集成到打包器项目中。

接下来,我们为打包器项目声明一个可执行文件,并纳入前面收集的文件:```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

this will link zlib to our packer

target_link_libraries(packer zlibstatic)

root@kitploit:~
你也可以只链接 `zlib`,如果你确实想要 zlib 的 DLL。

因为我们生成了文件(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 了解依赖关系来理顺依赖链:将 stub 标记为 packer 的依赖项,并将 packer 标记为 dummy 的依赖项。```cmake

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

add_dependencies(packer stub) add_dependencies(dummy packer)

root@kitploit:~
现在,[stub](https://github.com/frank2/packer-tutorial/blob/main/stub/CMakeLists.txt) 和 [dummy](https://github.com/frank2/packer-tutorial/blob/main/dummy/CMakeLists.txt) 的 CMake 文件应该相当容易理解了!

更棒的是,CMake 可以为我们管理测试!而且到目前为止,为我们的 packer 生成测试是一个顺畅的过程:我们只需执行一条命令,如果退出码为 0,测试即通过。为了简单起见,我们假设通过将二进制文件作为可执行文件的第一个参数来打包它。我们希望得到类似这样的内容:```
$ packer.exe dummy.exe

使用 CMake 做到这一点非常简单:```cmake

enable testing to verify our packer works

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

root@kitploit:~
最后,测试程序是否成功解包甚至更简单:直接运行输出文件即可!当您尝试模拟加载器时,仅仅运行就会导致程序崩溃,您会遇到多少错误,这不足为奇。再说一遍,为简单起见,假设该二进制文件应将输出命名为“packed.exe”以表示打包后的二进制文件。在这种情况下,您只需这样做:```cmake
add_test(NAME test_unpack
  COMMAND "packed.exe")

如果您的 packing 器未能输出二进制文件,此操作会优雅地失败。不幸的是,用于 Visual Studio 的 CMake 会忽略 ADDITIONAL_CLEAN_FILES 变量,因此您必须在构建系统中手动清理所有生成的文件,包括上面生成的 stub.rc 和 stub.hpp 文件。

恭喜!为了成功构建和测试您的 packing 器,已经完成了大量工作。既然我们已经吃完了蔬菜,我们的 packing 器现在可以做到以下几点:

  • 按正确顺序编译所有依赖项
  • 构建 stub 二进制文件并将其注入到我们的 packing 器二进制文件中
  • 自动测试打包/解包过程

现在我们可以进入正题了!

将二进制文件打包到您的 stub 中

我们已经成功地将编译器设置为把 stub 可执行文件作为资源编译进我们的 packing 器二进制文件,但我们如何将一个二进制文件以打包状态放入 stub 中呢?我们不能把它作为资源添加,因为我们不能指望 packing 器的最终用户去使用编译器,packing 器可执行文件应该是一个独立的解决方案。

我经常使用的一种技术(尽管对分析者来说可能很明显)是向 stub 二进制文件添加一个新节,以便最终在运行时加载。这同时也会成为 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]); }

root@kitploit:~
有了存储在易于操作的向量中的数据,我们现在可以解析存根镜像并向其中添加新数据。

### 解析 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 头部的偏移量,而 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); } }

root@kitploit:~
`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` 函数,它们允许你分块处理压缩流。参见 [zlib 手册](https://www.zlib.net/manual.html) 的“高级函数”一节。不过,对于这个示例,`compress` 和 `decompress` 就足够了。

首先,我们使用 zlib 的 `compressBound` 函数针对目标二进制文件的大小获取一个尺寸值。该尺寸值对应于在给定数据大小的情况下,容纳压缩数据流所需的最大值。然后我们可以使用这个值来分配一个 vector 以容纳压缩数据。`compress` 函数最终返回压缩缓冲区的真实大小,我们可以据此将 vector 调整为适当的大小。```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 文件

让我们花点时间来讨论数据对齐。如果给定数据流的地址或大小可被某个对齐边界整除,则认为该数据流是对齐的。例如,在 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; }

root@kitploit:~
该函数本质上为可能未对齐的值补齐所需余量,使其正确对齐到给定边界。

为了正确地向我们的 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;

root@kitploit:~
接下来,我们需要获取指向节表本身的指针。虽然从技术上讲,节表紧跟在NT头的可选头之后,但实际上可选头的大小是由NT文件头的 `SizeOfOptionalHeader` 值决定的。因此,为了到达那里,我们需要从 `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;

root@kitploit:~
对于我们的新节区头,我们感兴趣的特定变量是 `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) 的 characteristics 部分。目前,你只需要知道,我们需要的只是节区可读且标记为包含已初始化数据。```cpp
// get a pointer to our new section and the previous section
auto section = &section_table[section_index];
auto prev_section = &section_table[section_index-1];

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

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

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

不过,这现在引出一个小问题:镜像的大小变了。你可能会认为这不会是个问题,但在 NT 头的可选头中有一个名为 SizeOfImage 的变量,它决定加载器需要为我们的可执行文件分配多少空间。不过,这个问题很好解决:我们所需的镜像大小就是最后一个节按节对齐边界对齐后的大小。```cpp // calculate the new size of the image. nt_header->OptionalHeader.SizeOfImage = align(virtual_offset + virtual_size, section_alignment);

root@kitploit:~
就这样!我们已成功地将压缩后的二进制文件作为新节区添加到存根中,以便存根最终解压并加载。现在,我们可以简单地将修改后的存根映像保存到磁盘。```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();

祝贺!到目前为止,我们已经完成了以下工作:

  • 编译、注入并在运行时从我们的打包器的资源目录中取回了我们的存根二进制文件
  • 解析了我们的存根二进制文件和目标二进制文件的可执行文件头,以获取关键信息
  • 修改了一个可执行文件,扩展其节数据以包含我们打包的可执行文件

我们编写打包器的工作已经完成了一半!现在我们可以继续进入可以说是整个过程中最困难的部分:完善存根二进制文件。

模拟加载器

虽然在底层,编写解包存根的细节可能会变得复杂,但从根本上说,它只归结为几个步骤:

  • 检索映像数据:获取并解压缩(可选地反混淆)目标二进制表示形式,以供进一步处理。
  • 加载映像:这是目前最复杂的一步,在此处模拟加载器并准备目标映像以供执行。
  • 调用映像的入口点:通常称为“原始入口点”(OEP),这是将打包的二进制文件从加载阶段过渡到执行阶段的点。

这个过程非常简单,我们的主例程只需几个函数:```cpp int main(int argc, char *argv[]) { // first, decompress the image from our added section auto image = get_image();

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

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

// relocate the executable relocate(loaded_image);

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

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

return 0; }

root@kitploit:~
### 从内存中读取我们的 PE

首先,我们需要以某种方式从正在运行的二进制文件中获取由加壳器创建的节的数据。是否有可能在运行时获取正在运行的二进制文件的头?是的,完全可以![`GetModuleHandleA`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandlea) 在传入空参数时,最终会返回一个指向我们正在运行的 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 = &section_table[i];
      break;
   }
}

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

接下来,我们需要从二进制文件中解压我们的 stub 数据。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;

root@kitploit:~
如你所见,`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;

root@kitploit:~
因此,加载过程中简单的那部分已经结束了:我们从内存中取回了二进制文件,将其解包,并将其各个节重新映射到可执行内存区域。在映像准备就绪后,我们就可以深入探讨加载过程的细枝末节了。

### 解析 API 导入

在可选头中有一个称为 *数据目录* 的结构。该目录包含大量关于可执行文件的不同信息,例如映像导出的符号以及图标、位图等资源。在本教程中,我们将解析两个数据目录:**导入目录** 和 **重定位目录**。每个目录都是硬编码的,其索引可以在 [可选头的文档](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header32) 中找到(向下滚动到 `DataDirectory` 的说明)。如果某个数据目录的 `VirtualAddress` 值非 `null`,则该目录存在。```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);

root@kitploit:~
要解析 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 包含此可执行文件相对于由 Name RVA 给出的 DLL 所期望的导入信息。更令人困惑的是,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;

root@kitploit:~
该数据结构同时涵盖了导入与导出 thunk,因此可以忽略 `ForwarderString`。一个导入 thunk 既可以是 `Ordinal`,也可以是指向另一个结构 `IMAGE_IMPORT_BY_NAME` 的 RVA。*序号(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 被期望是一个以 null 结尾的 C 字符串。

由于二进制数据不包含类型,序号 与 导入项 通过 thunk 项中的最高有效位来区分。在 32 位和 64 位实现中,序号都包含在整数的低半部分。

我们的导入表的工作方式类似于 C 字符串——其最后一个条目是一个以 null 结尾的 OriginalFirstThunk,用于表示可能导入项的结束。我们的 thunk 数据也以同样的方式工作,通过 thunk 数组中的 null 条目来终止。

综上所述,以下是我们解析导入表的伪代码思路:``` for every import descriptor: load the dll parse the original and first thunk

root@kitploit:~
for every thunk:
    if ordinal bit set:
        import by ordinal
    else:
        import by name
        
    store import in first thunk
root@kitploit:~
有趣的是,尽管 `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。我们关心的是“动态基址”这一特性。对于不支持 ASLR 的二进制文件,有一种特殊的解包方式,但我们这里不涉及,因此如果我们的二进制文件不支持该特性,就会报错。(这更适合作为你的打包器中的错误,而不是 stub 中的错误,但为了 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); }

root@kitploit:~
接下来,我们需要计算 *地址差值*。这仅仅是映像的 `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;

root@kitploit:~
请注意被注释掉的 `TypeOffset` 数组,它实际上与此相关!重定位表由包含待调整地址的偏移量块组成,这些块通过 `VirtualAddress` RVA 标识。`TypeOffset` 数组包含编码后的字值,其中既包含重定位类型,也包含从 `VirtualAddress` 起需要调整的偏移量。就重定位类型而言,对于 64 位二进制文件,我们只需关注一种重定位类型。不幸的是,该结构实际上并不是一个变长结构体,因此我们必须进行一些指针运算才能获取 `TypeOffset` 数组。

如前所述,`TypeOffset` 数组包含编码后的字。高 4 位(掩码 `0xF000`)包含重定位类型,可在 PE 格式文档的[“基础重定位类型”部分](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)找到。低 12 位(掩码 `0x0FFF`)包含从 `VirtualAddress` 参数开始的待调整偏移量。

解释起来很麻烦,最终只会让人感到非常困惑。获取指向待重定位地址的指针的方式如下:```cpp
auto ptr = reinterpret_cast<std::uintptr_t *>(image + relocation_table->VirtualAddress + offset);

而调整该地址的方式如下:```cpp *ptr += delta;

root@kitploit:~
所以,尽管解释重定位目录(relocation directory)很复杂,但在代码中,这些操作实际上非常容易理解。

通过 `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; }

root@kitploit:~
正如你所看到的,转移执行非常简单,尽管确实需要了解[函数指针](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, ...)

root@kitploit:~
因此,我们用于调用入口点的函数指针——没有与其关联的类型——看起来像这样:```c
void (*entrypoint)()

作为一种类型转换,它归结为:```c void(*)()

root@kitploit:~
Putting everything together, we can simply cast our entrypoint as a function pointer and call it in the same line, like so:

综上所述,我们可以简单地将入口点强制转换为函数指针,并在同一行中调用它,如下所示:```cpp
reinterpret_cast<void(*)()>(entrypoint)();

一切加载正确后,您应该会看到打包后的程序运行。在我们的例子中,由于打包的是虚拟可执行文件,它只会输出一条消息:``` $ ./packed.exe I'm just a little guy!

root@kitploit:~
恭喜!你完成了!你刚刚编写了一个 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)。
下载工具