阅读时间: 8-10 分钟
前置知识: 理解内存映射文件检测原理、C/C++基础、Windows API基础
学习目标: 掌握PE文件结构解析,实现DLL内存镜像构建
配套视频教程
本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:
💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!
视频链接: https://www.bilibili.com/video/BV1AH2jBKEe8/
承接上节:为什么LDR断链还是被发现了?
上节课我们讲到,防御者为了对付LDR断链,祭出了 内存映射文件名检测 这一招。
他们的逻辑非常粗暴:直接把所有”带文件名的内存块”全扫一遍,跟PEB里的名单一一核对。只要发现这块内存里明明映射了某个DLL,但PEB里却没记录,那一定是有人在搞鬼。
这招确实狠,直接打穿了我们上一层的伪装。
但大家仔细想一下,这套检测逻辑的死穴在哪里?
攻击方的分析思路
死穴在这里:它太依赖”内存映射”这个特征了
防御者默认:所有的DLL,都必须通过系统机制映射到内存里。
为什么他们敢这么假设?
因为传统的 LoadLibrary 就是这么干的:它会调用底层API,把硬盘上的文件”投影”到内存里。这种内存块带有一个明显的标签叫 SEC_IMAGE(或者说 MEM_IMAGE 类型),扫描器一抓一个准。

打破思维定势
关键发现:上节课我们只断了PEB链,但DLL文件还在内存里”挂着”(映射),一查就露馅。
那如果我们换个思路呢?
如果不调用 LoadLibrary,也不走系统的文件映射,而是直接简单粗暴地申请一块 私有内存(VirtualAlloc),然后把DLL像读TXT文本一样读进去,自己把代码铺好。
手工映射的行为:
┌──────────────────────────────────────────────────────────┐
│ VirtualAllocEx(..., MEM_COMMIT, PAGE_READWRITE) │
│ ↓ │
│ ReadFile(hDllFile, ...) // 像读普通文件一样读DLL │
│ ↓ │
│ 内存块属性 = MEM_PRIVATE(普通私有内存) │
│ ↓ │
│ 防御者查询这块内存时看到: │
│ "这就是一块普通的私有数据,没有文件名,没有模块特征" │
└──────────────────────────────────────────────────────────┘
这时候,在系统和扫描器眼里,这块内存就是一堆毫无意义的”私有数据”,没有文件名,没有模块特征,自然也就躲过了”内存映射检测”。
绕过方案
重新定义LoadLibrary
这听起来很完美,但门槛也很高。
因为 LoadLibrary 虽然看着简单,背后其实是个超级勤劳的”搬运工”:
LoadLibrary 帮我们干的脏活累活:
┌─────────────────────────────────────────────────────────┐
│ 1. 读取DLL文件 │
│ 2. 解析PE结构 │
│ 3. 在内存中展开(按4KB对齐) │
│ 4. 修复导入表(填充外部函数地址) │
│ 5. 处理重定位(修正绝对地址) │
│ 6. 注册到PEB模块链表 ← 我们要跳过这步 │
│ 7. 调用DllMain入口函数 │
└─────────────────────────────────────────────────────────┘
一旦我们决定抛弃 LoadLibrary,步骤1-5和步骤7就得全部自己动手实现。
这就是大名鼎鼎的——手工映射注入(Manual Mapping),也叫内存加载。
说白了,就是我们要自己手写一个微型的Windows加载器。
路线图
这工程量不小,为了讲透,我们分三期来拆解:
|
期数 461_36d873-9b> |
内容 461_f491e6-ad> |
状态 461_bee20d-d8> |
|---|---|---|
|
上篇(本期) 461_c132d8-3a> |
PE结构基础 + 内存镜像构建 461_8c5211-8d> |
✅ 本期完成 461_de17b6-d4> |
|
中篇 461_1a7b79-2c> |
基址重定位 + 导入表修复 + 远程写入 461_3b214d-3a> |
⏳ 461_d0ed75-2b> |
|
下篇 461_970674-7c> |
线程劫持触发DllMain 461_d4fefc-de> |
⏳ 461_99402a-45> |
用”搬家”来比喻:
LoadLibrary= 找搬家公司(物业会登记你搬进来了)- 手工映射 = 自己偷偷搬运(物业根本不知道有人住进来)
好,话不多说,我们先来看第一步:如何完美复刻内存镜像。
动手实现:构建内存镜像
直奔主题:手工映射第一步——完美复刻内存镜像。
核心就一句话:DLL在硬盘里的布局,和内存里的布局,完全是两码事。
硬盘上的数据是紧凑排列的,为了省空间;内存里的数据是按页对齐的,为了配合CPU管理。你要是直接把文件内容原封不动拷过去,代码肯定跑不起来。
所以,理解PE结构,本质上就是理解”文件布局”怎么变成”内存布局”。
现在咱们动手写代码,边写边讲这些结构到底怎么用。
PE 的四部分结构
在写代码之前,先建立一个整体认知:一个 PE 文件,从头到尾就四部分。

|
部分 461_0aa8f2-c2> |
结构体 461_060752-86> |
一句话解释 461_d3d930-44> |
|---|---|---|
|
DOS头 461_bca3bd-20> |
|
PE文件的”门牌号”:验证文件合法性(MZ签名),并指向NT头的位置 461_4d8a93-66> |
|
NT头 461_211747-bd> |
|
PE文件的”户口本”:记录入口点、镜像大小、节区数量等所有加载信息 461_048162-dd> |
|
节表 461_145f6a-8b> |
|
PE文件的”楼层索引”:告诉你每个节(代码/数据)在文件和内存中的位置 461_78e822-31> |
|
节数据 461_39c3e7-8a> |
.text、.data、.rdata 等 461_108e08-ba> |
PE文件的”房间内容”:实际的代码和数据,按节表的描述来定位 461_fec9d9-22> |
接下来的代码,就是按顺序处理这四部分:
- 验证 DOS头和NT头的签名
- 复制 头部(DOS头 + NT头 + 节表)
- 展开 节数据到内存的正确位置
第一步:读取文件,验证 DOS头 和 NT头
先把DLL文件读进来,验证它是不是合法的PE文件——也就是检查前两部分(DOS头、NT头)的签名。
/**
* @brief 读取PE文件到内存并验证签名
* @param filePath PE文件完整路径
* @param pFileSize 返回文件大小
* @return 成功返回文件缓冲区,失败返回NULL
*/
LPVOID ReadPEFile(const char* filePath, DWORD* pFileSize)
{
// 打开文件
HANDLE hFile = CreateFileA(filePath, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) return NULL;
// 获取文件大小
DWORD fileSize = GetFileSize(hFile, NULL);
if (fileSize == INVALID_FILE_SIZE || fileSize < sizeof(IMAGE_DOS_HEADER))
{
CloseHandle(hFile);
return NULL;
}
// 分配内存并读取
LPVOID pFileBuffer = VirtualAlloc(NULL, fileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
DWORD bytesRead = 0;
ReadFile(hFile, pFileBuffer, fileSize, &bytesRead, NULL);
CloseHandle(hFile);文件读进来了,现在要验证它是不是PE文件。
DOS头是PE文件的第一个结构,共64字节。虽然大部分字段是DOS时代的遗留(1983年的设计),但有两个字段对我们至关重要:
e_magic:PE文件的第一个签名,必须是0x5A4D(即ASCII的”MZ”)e_lfanew:告诉我们NT头在文件的哪个位置
// 验证DOS签名 "MZ"
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBuffer;
if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) // 0x5A4D = "MZ"
{
VirtualFree(pFileBuffer, 0, MEM_RELEASE);
return NULL;
}NT头才是PE文件的核心,包含了加载和执行所需的所有关键信息。它由三部分组成:
Signature:PE签名,必须是0x4550(即”PE”)FileHeader:文件头,包含节区数量、机器类型等OptionalHeader:可选头(其实是必选的),包含入口点、镜像大小等
// 通过 e_lfanew 找到NT头,验证NT签名 "PE"
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBuffer + pDosHeader->e_lfanew);
if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE) // 0x4550 = "PE"
{
VirtualFree(pFileBuffer, 0, MEM_RELEASE);
return NULL;
}
if (pFileSize) *pFileSize = fileSize;
return pFileBuffer;
}这一步的定位逻辑:
pFileBuffer (文件基址)
│
├──→ pDosHeader (直接就是文件开头)
│ │
│ └──→ e_lfanew = 0xE0 (NT头偏移)
│
└──→ pFileBuffer + 0xE0 = pNtHeaders
第二步:分配内存,复制头部(DOS头 + NT头 + 节表)
文件验证通过了,现在要把它”铺”到内存里。这个过程叫做构建内存镜像。
第一个问题:分配多大的内存?
答案不是文件大小,而是 NT头 中的 SizeOfImage——这个字段告诉我们DLL加载到内存后占多大。
/**
* @brief 构建PE的内存镜像(文件布局 → 内存布局)
* @param pFileBase PE文件数据(磁盘布局)
* @param pImageSize 返回镜像大小
* @return 内存镜像指针,失败返回NULL
*/
LPVOID BuildMemoryImage(LPVOID pFileBase, DWORD* pImageSize)
{
// 获取PE头信息
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBase;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBase + pDosHeader->e_lfanew);
// 分配SizeOfImage大小的内存(不是文件大小!)
// SizeOfImage是PE在内存中展开后的总大小
DWORD imageSize = pNtHeaders->OptionalHeader.SizeOfImage;
LPVOID pMemoryImage = VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pMemoryImage) return NULL;
// 先清零整个镜像(处理.bss等未初始化区域)
memset(pMemoryImage, 0, imageSize);
// 复制PE头(DOS头 + NT头 + 节表)
// 这部分在文件和内存中布局相同,直接复制
memcpy(pMemoryImage, pFileBase, pNtHeaders->OptionalHeader.SizeOfHeaders);这一步用到的PE结构:
|
字段 461_3dfffb-e7> |
位置 461_e76102-37> |
作用 461_1eefb0-ef> |
|---|---|---|
|
|
NT头.OptionalHeader 461_092888-fe> |
内存镜像总大小,分配内存用 461_2ccd27-46> |
|
|
NT头.OptionalHeader 461_9118bf-79> |
PE头总大小,复制头部用 461_969e31-20> |
第三步:根据节表,展开节数据
PE头(包含节表)复制完了,现在要把第四部分——节数据(.text、.data等)复制到内存的正确位置。
这是最关键的一步,因为文件里的位置和内存里的位置不一样。而节表,正是告诉我们”从哪复制到哪”的对照表。
节表是什么? 紧跟在NT头后面的一个数组,每个元素描述一个节:
节表 (IMAGE_SECTION_HEADER[]):
┌────────────┬──────────────────────────────────────────────┐
│ 字段 │ 作用 │
├────────────┼──────────────────────────────────────────────┤
│ Name │ 节名称(.text、.data等,最多8字节) │
│ VirtualSize│ 节的实际内容大小 │
│ VirtualAddress │ 节在【内存】中的偏移(相对于镜像基址) │
│ SizeOfRawData │ 节在【文件】中占用的大小(含填充) │
│ PointerToRawData │ 节在【文件】中的偏移 │
│ Characteristics │ 节属性(可读/可写/可执行) │
└────────────┴──────────────────────────────────────────────┘
现在遍历节表,把每个节的数据复制到正确位置:
// 复制每个节到内存中的正确位置
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders);
for (int i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++)
{
// 节表里有4个关键字段,决定了"从哪复制到哪":
// PointerToRawData = 节在【文件】中的偏移
// VirtualAddress = 节在【内存】中的偏移(相对于镜像基址)
// SizeOfRawData = 节在文件中占用的大小(按512字节对齐)
// VirtualSize = 节的实际内容大小
// 目标地址:内存镜像基址 + 节的VirtualAddress
LPVOID pDest = (BYTE*)pMemoryImage + pSection[i].VirtualAddress;
// 源地址:文件基址 + 节的PointerToRawData
LPVOID pSrc = (BYTE*)pFileBase + pSection[i].PointerToRawData;
// 【关键】复制大小:取 min(SizeOfRawData, VirtualSize)
DWORD copySize = min(pSection[i].SizeOfRawData, pSection[i].Misc.VirtualSize);
if (copySize > 0)
{
memcpy(pDest, pSrc, copySize);
}
}
if (pImageSize) *pImageSize = imageSize;
return pMemoryImage;
}为什么要用 min(SizeOfRawData, VirtualSize)?
这是一个常见的坑,两种特殊情况都会出问题:
|
情况 461_eff69d-ef> |
VirtualSize 461_36d8b8-97> |
SizeOfRawData 461_5af9eb-f0> |
问题 461_776b4a-7e> |
|---|---|---|---|
|
普通节(.text) 461_603692-03> |
0x1CC2 461_0589ff-7a> |
0x1E00 461_7e3999-db> |
SizeOfRawData 包含填充字节,不应该全复制 461_92d91c-95> |
|
未初始化数据(.bss) 461_1bf63f-38> |
0x1000 461_ef4c0e-61> |
0x0000 461_7af577-73> |
文件中根本没有数据,用VirtualSize会越界 461_5dff2e-37> |
正确做法:取两者最小值,既不会复制多余填充,也不会越界读取。
本期代码
/**
* @brief 读取PE文件到内存并验证签名
* @param filePath PE文件完整路径
* @param pFileSize 返回文件大小
* @return 成功返回文件缓冲区,失败返回NULL
* @details 读取原始PE文件(磁盘布局),验证DOS和NT签名
*/
LPVOID ReadPEFile(const char* filePath, DWORD* pFileSize)
{
// 打开文件
HANDLE hFile = CreateFileA(filePath,GENERIC_READ,FILE_SHARE_READ,NULL,OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL,NULL );
if (hFile == INVALID_HANDLE_VALUE)
{
std::cout << " 失败:无法打开文件,错误码: " << GetLastError() << "n";
return NULL;
}
// 获取文件大小
DWORD fileSize = GetFileSize(hFile, NULL);
if (fileSize == INVALID_FILE_SIZE || fileSize < sizeof(IMAGE_DOS_HEADER))
{
std::cout << " 失败:文件大小无效n";
CloseHandle(hFile);
return NULL;
}
// 分配内存缓冲区
LPVOID pFileBuffer = VirtualAlloc(NULL, fileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pFileBuffer)
{
std::cout << " 失败:无法分配本地内存n";
CloseHandle(hFile);
return NULL;
}
// 读取文件内容
DWORD bytesRead = 0;
if (!ReadFile(hFile, pFileBuffer, fileSize, &bytesRead, NULL) || bytesRead != fileSize)
{
std::cout << " 失败:无法读取文件内容n";
VirtualFree(pFileBuffer, 0, MEM_RELEASE);
CloseHandle(hFile);
return NULL;
}
CloseHandle(hFile);
// 验证DOS签名
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBuffer;
if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE)
{
std::cout << " 失败:无效的DOS签名(应为'MZ')n";
VirtualFree(pFileBuffer, 0, MEM_RELEASE);
return NULL;
}
// 检查PE头偏移
if (pDosHeader->e_lfanew > fileSize - sizeof(IMAGE_NT_HEADERS))
{
std::cout << " 失败:无效的PE头偏移n";
VirtualFree(pFileBuffer, 0, MEM_RELEASE);
return NULL;
}
// 验证NT签名
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBuffer + pDosHeader->e_lfanew);
if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE)
{
std::cout << " 失败:无效的NT签名(应为'PE')n";
VirtualFree(pFileBuffer, 0, MEM_RELEASE);
return NULL;
}
// 返回结果
if (pFileSize)
{
*pFileSize = fileSize;
}
return pFileBuffer;
}
/**
* @brief 构建PE的内存镜像(文件布局 → 内存布局)
*
* 【为什么需要这一步】
* PE文件在磁盘上是紧凑排列(节省空间),在内存中需要按VirtualAddress对齐。
*
* 【类比】
* - 磁盘文件 = 压缩包(紧凑,但不能直接用)
* - 内存镜像 = 解压后的文件夹(占空间,但可以执行)
*
* 【操作步骤】
* 1. 分配SizeOfImage大小内存(不是文件大小!)
* 2. 复制PE头(包含DOS头+NT头+节表)
* 3. 复制每个节到VirtualAddress位置(使用 min(SizeOfRawData, VirtualSize))
* 4. .bss节用0填充(VirtualSize > SizeOfRawData的部分)
*
* 【重要】复制大小计算:
* - 错误做法: if (SizeOfRawData <= VirtualSize) → 会导致.text等节完全不复制
* - 正确做法: copySize = min(SizeOfRawData, VirtualSize)
* - 原因: 文件对齐(0x200)和内存对齐(0x1000)不同
* 例如: .text节 VirtualSize=0x1CC2, SizeOfRawData=0x1E00
* 如果用 <= 判断会跳过,导致代码段全是0!
*
* 【内存布局示意】
* ┌─────────────┐ ← pMemoryImage (基址0x0)
* │ PE头 │ 拷贝SizeOfHeaders字节
* ├─────────────┤ ← +VirtualAddress[0] (例如0x1000)
* │ .text节 │ 拷贝SizeOfRawData字节
* ├─────────────┤ ← +VirtualAddress[1] (例如0x3000)
* │ .data节 │ 拷贝SizeOfRawData字节
* ├─────────────┤
* │ .bss节 │ 用0填充VirtualSize字节(未初始化数据)
* └─────────────┘ ← +SizeOfImage
*
* @param pFileBase PE文件数据(磁盘布局)
* @param pImageSize 返回镜像大小
* @return 内存镜像指针,失败返回NULL
*/
LPVOID BuildMemoryImage(LPVOID pFileBase, DWORD* pImageSize)
{
// 获取PE头信息
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBase;
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBase + pDosHeader->e_lfanew);
// 分配SizeOfImage大小的内存(不是文件大小!)
// SizeOfImage是PE在内存中展开后的总大小
DWORD imageSize = pNtHeaders->OptionalHeader.SizeOfImage;
LPVOID pMemoryImage = VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pMemoryImage)
{
return NULL;
}
// 先清零整个镜像(处理.bss等未初始化区域)
memset(pMemoryImage, 0, imageSize);
// 复制PE头(DOS头+NT头+节表)
// 这部分在文件和内存中布局相同,直接复制
memcpy(pMemoryImage, pFileBase, pNtHeaders->OptionalHeader.SizeOfHeaders);
std::cout << " 复制PE头: " << std::dec << pNtHeaders->OptionalHeader.SizeOfHeaders << " 字节n";
// 复制每个节到内存中的正确位置
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders);
for (int i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++)
{
// 目标地址:内存镜像基址 + 节的VirtualAddress
LPVOID pDest = (BYTE*)pMemoryImage + pSection[i].VirtualAddress;
// 源地址:文件基址 + 节的PointerToRawData
LPVOID pSrc = (BYTE*)pFileBase + pSection[i].PointerToRawData;
// 复制大小:取 min(SizeOfRawData, VirtualSize)
// 原因:文件对齐(0x200)和内存对齐(0x1000)不同,SizeOfRawData 可能 > VirtualSize
// 例如:.text节 VirtualSize=0x1CC2, SizeOfRawData=0x1E00(向上对齐到512倍数)
DWORD copySize = min(pSection[i].SizeOfRawData, pSection[i].Misc.VirtualSize);
if (copySize > 0)
{
memcpy(pDest, pSrc, copySize);
}
// 处理.bss节:VirtualSize > copySize的部分用0填充
// 例如:.data节中的未初始化全局变量
if (pSection[i].Misc.VirtualSize > copySize)
{
DWORD bssSize = pSection[i].Misc.VirtualSize - copySize;
memset((BYTE*)pDest + copySize, 0, bssSize);
}
// 输出节信息(Name字段不保证null结尾,需要安全处理)
char sectionName[9] = {0};
memcpy(sectionName, pSection[i].Name, 8);
std::cout << " 映射节区: " << sectionName;
for (int j = 0; j < 8 - strlen(sectionName); j++)
{
std::cout << " ";
}
std::cout << " VirtualAddress=0x" << std::hex << pSection[i].VirtualAddress;
std::cout << " VirtualSize=" << std::dec << pSection[i].Misc.VirtualSize;
std::cout << " (复制了 " << copySize << " 字节)n";
}
// 返回结果
if (pImageSize)
{
*pImageSize = imageSize;
}
return pMemoryImage;
}本节小结
PE 四部分 vs 我们做了什么
|
PE 部分 461_702fd7-43> |
结构体 461_862309-00> |
我们的操作 461_1a0865-57> |
用到的关键字段 461_b0ccc7-e0> |
|---|---|---|---|
|
1. DOS头 461_76d27b-c2> |
|
验证 “MZ” 签名 461_5285a6-54> |
|
|
2. NT头 461_e6cd80-8b> |
|
验证 “PE” 签名,读取镜像大小 461_8262ab-76> |
|
|
3. 节表 461_1e4bbe-f7> |
|
遍历,确定每个节的复制参数 461_9801e9-87> |
|
|
4. 节数据 461_a41073-a9> |
.text、.data 等 461_2a6eaa-13> |
按节表描述,复制到内存正确位置 461_a0e75d-d5> |
(由节表描述) 461_8a022f-71> |
代码函数对照
|
函数 461_f5c961-22> |
处理的 PE 部分 461_3c1b6e-19> |
|---|---|
|
|
读取整个文件,验证 DOS头、NT头 461_5bab6c-97> |
|
|
复制头部(DOS头+NT头+节表),展开节数据 461_53c770-d8> |
PE结构速查

下节预告
内存镜像构建好了,但这个镜像还不能直接运行,有两个致命问题:
问题1:地址全错了
DLL 在编译时,编译器假设它会被加载到一个”理想地址”(比如 0x10000000),代码里的跳转、全局变量访问都是按这个地址算的。
但我们用 VirtualAlloc 分配的内存,地址是随机的(比如 0x01A50000)。这时候代码里写死的地址全都指向了错误的位置。
问题2:外部函数调用是空的
DLL 里调用了 MessageBoxA、GetProcAddress 这些外部函数,但编译时只留了个”占位符”,真正的地址要等加载时才填进去。
LoadLibrary 会帮我们填,但我们现在是手工加载,这些占位符还是空的——一调用就崩溃。
下节课解决这两个问题:
- 基址重定位:把代码里的”理想地址”修正成”实际地址”
- 导入表修复:找到外部函数的真实地址,填进占位符里
- 将完整镜像写入目标进程
推荐阅读:
