尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零实现反射式DLL加载器:内存加载原理与PE结构详解

从零实现反射式DLL加载器:内存加载原理与PE结构详解 在 Windows C 开发中最常见的动态模块加载方式是LoadLibrary(C:\\...\\plugin.dll)。当模块被打包进资源、需要先做鉴权或解密再加载或者希望避免中间文件落盘时这种“必须提供磁盘路径”的方式就变得很僵硬。此时可以考虑自己实现一个最小可用的反射式 DLL 加载器从内存中的 PE 文件数据开始完成节区映射、重定位、导入表修复、入口调用和导出函数查询。这篇文章会从零写一个面向 x64 的实验版本并解释每一步为什么要这么做。反射式 DLL 加载并不是一个新的框架而是一套围绕 PE 文件格式展开的手工装载流程。理解它之后你不仅能够写一个不依赖LoadLibrary的内存模块加载器也能更清楚地知道 Windows 系统加载 DLL 时到底做了什么。1. 先理解反射式 DLL 加载器为什么 LoadLibrary 不够反射式在做什么1.1 从 LoadLibrary 的局限谈起LoadLibrary是 Windows 提供的标准模块加载 API。它接收一个 DLL 文件路径从磁盘读取文件解析 PE 结构分配映像内存修复重定位加载依赖项填充导入地址表最后调用 DLL 入口。这个链路经过微软多年打磨稳定可靠但它有几个内置限制必须先有一个可读取的 DLL 文件路径。加载过程中文件会被系统打开路径、签名、行为都可能被安全软件观察。模块被LoadLibrary加载后会进入进程的模块链表中GetModuleHandle、GetProcAddress等可以按常规方式查询。如果你想在加载前先解密、解压、校验或从网络流接收模块仍然需要先落盘然后才能传给LoadLibrary。在资源加密分发、自研插件系统、自制沙箱类工具等场景中“落盘后再加载”会引入额外风险。文件一旦落地就可能在加载前被替换或扫描。如果能让加载器直接读取内存中的 PE 缓冲区并把缓冲区转换成一块可以调用的模块映像上述限制就少了一半。这正是“反射式加载”这个名字要表达的方向不经过系统自带的LoadLibrary而是自己按 PE 文件的结构描述在进程内重新执行一遍系统 Loader 的关键流程。1.2 反射式加载的核心步骤一个典型的 PE 内存加载流程包含以下步骤校验输入缓冲区中的 DOS 签名MZ、NT 签名PE\0\0。读取IMAGE_NT_HEADERS确认是 32 位还是 64 位 PE。根据SizeOfImage分配一块可读可写的连续虚拟内存。把 DOS 头、NT 头、节区头等所有头部拷贝到新内存中。遍历节区表把每个节区从文件偏移拷贝到虚拟地址对应的位置。计算实际加载地址与 PE 头中ImageBase的差值并用这个差值修复重定位表。遍历导入表用LoadLibrary加载依赖 DLL用GetProcAddress获取函数地址并写入导入地址表 IAT。如果有 TLS 目录和 TLS 回调则需要调用相关回调函数。调用入口点如果是 DLL通常调用DllMain并传入DLL_PROCESS_ATTACH。加载完成后通过自写导出表解析函数得到导出函数地址。为了降低学习复杂度本文的实验版本只支持 64 位 PE不支持延迟导入、纯资源 DLL、异常处理目录、静态 TLS 全流程。但它足以展示 Windows Loader 最重要的三个底层动作节区映射、重定位、导入修复。1.3 使用边界与风险提示这个技术思路可以被用在恶意代码中常见的滥用方式是远程线程注入、进程 hollowing、隐藏模块加载。本文只讨论“在当前进程内加载自己拥有或授权的模块”这一正当开发视角不会涉及跨进程注入、绕过安全机制或隐藏执行行为。实际使用时也要遵守几条底线不要加载没有授权、无法验证来源的模块。不要把这个代码直接改造成远程注入器。不要在真实生产系统里加载未知样本。如果只是做防御研究应该在隔离环境中加载可控样例并记录日志。先明确边界再研究原理这样得到的知识才是可用的工程能力。2. 实验环境与项目结构把一个最小 DLL 准备好2.1 环境要求这个项目不依赖第三方库只使用 Windows API 和 C/C 标准库。推荐环境如下项目建议配置操作系统Windows 10 或 Windows 11 的 x64 版本编译器Visual Studio 2019 或 2022安装“使用 C 的桌面开发”编译目标x64 应用程序和 x64 DLL构建方式Visual Studio 解决方案或 x64 开发者命令行可选工具dumpbin.exe用于检查 DLL 头、导出表和依赖项打开“x64 Native Tools Command Prompt for VS 2022”后可以直接使用cl.exe和link.exe也可以直接打开 Visual Studio 创建控制台工程和 DLL 工程。本文示例代码按 VS 工程组织但所有核心逻辑都可以放进单个.cpp文件验证。2.2 目录结构建议按下面结构组织实验代码ReflectiveLoader/ ├── memory_dll/ │ └── MemoryDll.cpp ├── host_app/ │ ├── MemoryLoader.h │ ├── MemoryLoader.cpp │ └── main.cpp └── README.mdMemoryDll.cpp是被加载的测试模块MemoryLoader.cpp是手写加载器main.cpp负责把 DLL 文件内容读入内存然后调用自写加载器完成验证。2.3 先准备一个最小的测试 DLL测试 DLL 不能太复杂否则很难判断错误是 PE 加载造成还是 DLL 自身问题造成。这里只导出一个整数加法函数和一个返回模块名常量字符串的函数。在MemoryDll.cpp中写入#include windows.h extern C __declspec(dllexport) int Add(int a, int b) { return a b; } extern C __declspec(dllexport) const char* ModuleName() { return memory-dll; } BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) { return TRUE; }这里有两个关键点使用extern C避免 C 名字修饰这样加载器在导出表中查找Add和ModuleName时不会遇到?AddYAHHHZ这类修饰名。DllMain里不写静态初始化逻辑避免加载器还没完整处理 CRT 生命周期时引入额外变量。编译时用 Visual Studio 创建一个“动态链接库 (DLL)”项目把MemoryDll.cpp加入目标平台选 x64编译得到MemoryDll.dll。后面会用它作为手写加载器的输入。3. 手写 Loader 第一步PE 结构解析与节区映射3.1 PE 文件的整体读取顺序PE 文件在磁盘上的形态和在内存中的形态并不完全相同。磁盘上文件按IMAGE_DOS_HEADER、e_lfanew偏移、IMAGE_NT_HEADERS、节区表、节区数据排布。加载到内存后节区按VirtualAddress展开整体布局更接近一个连续映像。在 C 中操作 PE 结构不要凭经验猜测偏移而是用 Windows SDK 提供的结构体。核心的读取顺序是读取IMAGE_DOS_HEADER判断e_magic是否为IMAGE_DOS_SIGNATURE。由e_lfanew定位到IMAGE_NT_HEADERS64。检查Signature是否为IMAGE_NT_SIGNATURE。检查OptionalHeader.Magic是否为IMAGE_NT_OPTIONAL_HDR64_MAGIC。通过IMAGE_FIRST_SECTION取得节区表起始位置。3.2 校验输入缓冲区下面的代码负责检查缓冲区是否是一份完整的 x64 PE 文件static IMAGE_NT_HEADERS64* GetNtHeaders( const unsigned char* data, size_t size) { if (data nullptr || size sizeof(IMAGE_DOS_HEADER)) { return nullptr; } const auto* dos reinterpret_castconst IMAGE_DOS_HEADER*(data); if (dos-e_magic ! IMAGE_DOS_SIGNATURE) { return nullptr; } if (dos-e_lfanew 0 || size static_castsize_t(dos-e_lfanew) sizeof(IMAGE_NT_HEADERS64)) { return nullptr; } auto* nt reinterpret_castIMAGE_NT_HEADERS64*( const_castunsigned char*(data) dos-e_lfanew); if (nt-Signature ! IMAGE_NT_SIGNATURE) { return nullptr; } if (nt-OptionalHeader.Magic ! IMAGE_NT_OPTIONAL_HDR64_MAGIC) { return nullptr; } return nt; }这个函数只做结构校验。它没有校验节区表是否越界生产环境必须把data offset length是否越界加入检查。对于实验版本先保证输入来源是自己编译的 DLL。3.3 分配映像内存并复制节区校验通过后使用VirtualAlloc分配SizeOfImage大小的内存。这里选择可读可写权限BYTE* image static_castBYTE*( VirtualAlloc( nullptr, OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));随后开始复制头部和节区static void CopySectionData( BYTE* image, const unsigned char* data, const IMAGE_NT_HEADERS64* nt) { memcpy(image, data, nt-OptionalHeader.SizeOfHeaders); const IMAGE_SECTION_HEADER* section IMAGE_FIRST_SECTION(nt); for (WORD i 0; i nt-FileHeader.NumberOfSections; i) { if (section[i].SizeOfRawData 0) { continue; } memcpy( image section[i].VirtualAddress, data section[i].PointerToRawData, section[i].SizeOfRawData); } }这里要注意VirtualAlloc返回的内存已经清零所以不需要手动把节区虚拟大小和原始大小之间的差异区域清零。SizeOfRawData表示文件中节区数据的字节数VirtualAddress表示加载后映像内相对基址的偏移。两者必须组合使用。3.4 为什么先做节区映射再处理重定位和导入表重定位表、导入表、导出表在 PE 文件中存储的是 RVA例如某个指针需要被修正到image 0x1000。只有先把节区完整 copy 到image缓冲区才能通过image rva的方式安全访问这些表。如果先做重定位此时目标地址的内存还不存在写入必然失败。如果先处理导入表又因为节区还没有复制Name字段指向的依赖模块名也可能读错。因此顺序必须是分配内存、复制头部、复制节区、修复重定位、填充导入表、调用入口。4. 手写 Loader 第二步重定位、导入表、导出表与入口调用4.1 修复重定位表加载地址与 ImageBase 的差值决定一切PE 在编译和链接时有一个首选基址ImageBase。链接器生成的大量绝对地址都建立在“模块会被加载到 ImageBase”这个假设上。如果VirtualAlloc分配到的地址不是ImageBase就必须把所有绝对地址修正为“实际加载地址 - ImageBase 原始值”。在 x64 上绝大多数重定位类型是IMAGE_REL_BASED_DIR64即 64 位绝对地址。X86 上常见的是IMAGE_REL_BASED_HIGHLOW。下面的函数只处理 x64static bool FixRelocations( BYTE* image, const IMAGE_NT_HEADERS64* nt) { ULONGLONG imageBase nt-OptionalHeader.ImageBase; ULONGLONG delta reinterpret_castULONGLONG(image) - imageBase; if (delta 0) { return true; } const auto relocDir nt-OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir.VirtualAddress 0 || relocDir.Size 0) { return false; } size_t offset 0; while (offset relocDir.Size) { auto* block reinterpret_castIMAGE_BASE_RELOCATION*( image relocDir.VirtualAddress offset); if (block-SizeOfBlock 0) { break; } size_t count (block-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* entries reinterpret_castWORD*( reinterpret_castBYTE*(block) sizeof(IMAGE_BASE_RELOCATION)); for (size_t i 0; i count; i) { WORD type entries[i] 12; WORD offsetInPage entries[i] 0x0FFF; if (type IMAGE_REL_BASED_ABSOLUTE) { continue; } if (type ! IMAGE_REL_BASED_DIR64) { return false; } ULONGLONG* patch reinterpret_castULONGLONG*( image block-VirtualAddress offsetInPage); *patch delta; } offset block-SizeOfBlock; } return true; }如果 DLL 没有重定位表同时系统分配的内存地址又恰好不是ImageBase这个 DLL 不能安全加载。加载器应该返回失败而不是强行调用入口点否则代码里的绝对地址全部指向错误位置
返回列表