阅读时间: 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 类型),扫描器一抓一个准。

Windows内存映射原理架构

关键发现:上节课我们只断了PEB链,但DLL文件还在内存里”挂着”(映射),一查就露馅。

打破思维定势

那如果我们换个思路呢?

如果不调用 LoadLibrary,也不走系统的文件映射,而是直接简单粗暴地申请一块 私有内存VirtualAlloc),然后把DLL像读TXT文本一样读进去,自己把代码铺好。

1
2
3
4
5
6
7
8
9
10
11
手工映射的行为:
┌──────────────────────────────────────────────────────────┐
│ VirtualAllocEx(..., MEM_COMMIT, PAGE_READWRITE) │
│ ↓ │
│ ReadFile(hDllFile, ...) // 像读普通文件一样读DLL │
│ ↓ │
│ 内存块属性 = MEM_PRIVATE(普通私有内存) │
│ ↓ │
│ 防御者查询这块内存时看到: │
│ "这就是一块普通的私有数据,没有文件名,没有模块特征" │
└──────────────────────────────────────────────────────────┘

这时候,在系统和扫描器眼里,这块内存就是一堆毫无意义的”私有数据”,没有文件名,没有模块特征,自然也就躲过了”内存映射检测”。


绕过方案

重新定义LoadLibrary

这听起来很完美,但门槛也很高。

因为 LoadLibrary 虽然看着简单,背后其实是个超级勤劳的”搬运工”:

1
2
3
4
5
6
7
8
9
10
LoadLibrary 帮我们干的脏活累活:
┌─────────────────────────────────────────────────────────┐
│ 1. 读取DLL文件 │
│ 2. 解析PE结构 │
│ 3. 在内存中展开(按4KB对齐) │
│ 4. 修复导入表(填充外部函数地址) │
│ 5. 处理重定位(修正绝对地址) │
│ 6. 注册到PEB模块链表 ← 我们要跳过这步 │
│ 7. 调用DllMain入口函数 │
└─────────────────────────────────────────────────────────┘

一旦我们决定抛弃 LoadLibrary,步骤1-5和步骤7就得全部自己动手实现

这就是大名鼎鼎的——手工映射注入(Manual Mapping),也叫内存加载

说白了,就是我们要自己手写一个微型的Windows加载器。

路线图

这工程量不小,为了讲透,我们分三期来拆解:

期数 内容 状态
上篇(本期) PE结构基础 + 内存镜像构建 ✅ 本期完成
中篇 基址重定位 + 导入表修复 + 远程写入
下篇 线程劫持触发DllMain

用”搬家”来比喻

  • LoadLibrary = 找搬家公司(物业会登记你搬进来了)
  • 手工映射 = 自己偷偷搬运(物业根本不知道有人住进来)

好,话不多说,我们先来看第一步:如何完美复刻内存镜像。


动手实现:构建内存镜像

直奔主题:手工映射第一步——完美复刻内存镜像。

核心就一句话:DLL在硬盘里的布局,和内存里的布局,完全是两码事。

硬盘上的数据是紧凑排列的,为了省空间;内存里的数据是按页对齐的,为了配合CPU管理。你要是直接把文件内容原封不动拷过去,代码肯定跑不起来。

所以,理解PE结构,本质上就是理解”文件布局”怎么变成”内存布局”。

现在咱们动手写代码,边写边讲这些结构到底怎么用。


PE 的四部分结构

在写代码之前,先建立一个整体认知:一个 PE 文件,从头到尾就四部分

PE文件四部分结构

部分 结构体 一句话解释
DOS头 IMAGE_DOS_HEADER PE文件的”门牌号”:验证文件合法性(MZ签名),并指向NT头的位置
NT头 IMAGE_NT_HEADERS PE文件的”户口本”:记录入口点、镜像大小、节区数量等所有加载信息
节表 IMAGE_SECTION_HEADER[] PE文件的”楼层索引”:告诉你每个节(代码/数据)在文件和内存中的位置
节数据 .text、.data、.rdata 等 PE文件的”房间内容”:实际的代码和数据,按节表的描述来定位

接下来的代码,就是按顺序处理这四部分:

  1. 验证 DOS头和NT头的签名
  2. 复制 头部(DOS头 + NT头 + 节表)
  3. 展开 节数据到内存的正确位置

第一步:读取文件,验证 DOS头 和 NT头

先把DLL文件读进来,验证它是不是合法的PE文件——也就是检查前两部分(DOS头、NT头)的签名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @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头在文件的哪个位置
1
2
3
4
5
6
7
// 验证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\0\0”)
  • FileHeader:文件头,包含节区数量、机器类型等
  • OptionalHeader:可选头(其实是必选的),包含入口点、镜像大小等
1
2
3
4
5
6
7
8
9
10
    // 通过 e_lfanew 找到NT头,验证NT签名 "PE\0\0"
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;
}

这一步的定位逻辑

1
2
3
4
5
6
7
pFileBuffer (文件基址)

├──→ pDosHeader (直接就是文件开头)
│ │
│ └──→ e_lfanew = 0xE0 (NT头偏移)

└──→ pFileBuffer + 0xE0 = pNtHeaders

第二步:分配内存,复制头部(DOS头 + NT头 + 节表)

文件验证通过了,现在要把它”铺”到内存里。这个过程叫做构建内存镜像

第一个问题:分配多大的内存?

答案不是文件大小,而是 NT头 中的 SizeOfImage——这个字段告诉我们DLL加载到内存后占多大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @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结构

字段 位置 作用
SizeOfImage NT头.OptionalHeader 内存镜像总大小,分配内存用
SizeOfHeaders NT头.OptionalHeader PE头总大小,复制头部用

第三步:根据节表,展开节数据

PE头(包含节表)复制完了,现在要把第四部分——节数据(.text、.data等)复制到内存的正确位置。

这是最关键的一步,因为文件里的位置和内存里的位置不一样。而节表,正是告诉我们”从哪复制到哪”的对照表。

节表是什么? 紧跟在NT头后面的一个数组,每个元素描述一个节:

1
2
3
4
5
6
7
8
9
10
11
节表 (IMAGE_SECTION_HEADER[]):
┌────────────┬──────────────────────────────────────────────┐
│ 字段 │ 作用 │
├────────────┼──────────────────────────────────────────────┤
│ Name │ 节名称(.text、.data等,最多8字节) │
│ VirtualSize│ 节的实际内容大小 │
│ VirtualAddress │ 节在【内存】中的偏移(相对于镜像基址) │
│ SizeOfRawData │ 节在【文件】中占用的大小(含填充) │
│ PointerToRawData │ 节在【文件】中的偏移 │
│ Characteristics │ 节属性(可读/可写/可执行) │
└────────────┴──────────────────────────────────────────────┘

现在遍历节表,把每个节的数据复制到正确位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    // 复制每个节到内存中的正确位置
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)

这是一个常见的坑,两种特殊情况都会出问题:

情况 VirtualSize SizeOfRawData 问题
普通节(.text) 0x1CC2 0x1E00 SizeOfRawData 包含填充字节,不应该全复制
未初始化数据(.bss) 0x1000 0x0000 文件中根本没有数据,用VirtualSize会越界

正确做法:取两者最小值,既不会复制多余填充,也不会越界读取。


本期代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
* @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 部分 结构体 我们的操作 用到的关键字段
1. DOS头 IMAGE_DOS_HEADER 验证 “MZ” 签名 e_magice_lfanew
2. NT头 IMAGE_NT_HEADERS 验证 “PE” 签名,读取镜像大小 SignatureSizeOfImageSizeOfHeaders
3. 节表 IMAGE_SECTION_HEADER[] 遍历,确定每个节的复制参数 VirtualAddressPointerToRawDataVirtualSizeSizeOfRawData
4. 节数据 .text、.data 等 按节表描述,复制到内存正确位置 (由节表描述)

代码函数对照

函数 处理的 PE 部分
ReadPEFile() 读取整个文件,验证 DOS头、NT头
BuildMemoryImage() 复制头部(DOS头+NT头+节表),展开节数据

PE结构速查

PE结构速查

下节预告

内存镜像构建好了,但这个镜像还不能直接运行,有两个致命问题:

问题1:地址全错了

DLL 在编译时,编译器假设它会被加载到一个”理想地址”(比如 0x10000000),代码里的跳转、全局变量访问都是按这个地址算的。

但我们用 VirtualAlloc 分配的内存,地址是随机的(比如 0x01A50000)。这时候代码里写死的地址全都指向了错误的位置。

问题2:外部函数调用是空的

DLL 里调用了 MessageBoxAGetProcAddress 这些外部函数,但编译时只留了个”占位符”,真正的地址要等加载时才填进去。

LoadLibrary 会帮我们填,但我们现在是手工加载,这些占位符还是空的——一调用就崩溃。

下节课解决这两个问题:

  • 基址重定位:把代码里的”理想地址”修正成”实际地址”
  • 导入表修复:找到外部函数的真实地址,填进占位符里
  • 将完整镜像写入目标进程

推荐阅读