模块 50 - 解析 PE 头 (PE Headers)
恶意软件开发课程 - 解析 PE 头 (PE Headers)
模块 50 - 解析 PE 头 (PE Headers)#
[!IMPORTANT] 本知识库声明
本知识库由本人整理自互联网 MalDev Academy 泄露资源,并由本人手动翻译为中文,过程中增加了大量关键技术提示与实践心得。
- 内容完整性:并未修改任何核心代码与技术逻辑,仅做汉化与注释加强。
- 版权归属:原始知识产权归原作者/官方所有。
- 支持正版:本仓库仅供内部学习交流,如果您有经济能力,请务必支持正版课程。
- 权利申诉:如相关内容侵犯了您的权益,请联系我,我将立即核实并删除。
解析 PE 头#
简介#
在之前的初学者模块中,我们简要讨论了 PE 文件格式的结构。当时的模块侧重于理论,而不是从编程角度讨论如何访问每个头。本模块将讲解提取 PE 文件组件的过程,并更深入地了解文件结构,这将最终成为学习更高级模块的先决条件。
如果您对 PE 结构还不够理解,请复习之前的 PE 文件结构介绍模块。
PE 结构#
回顾介绍模块中的下图,它显示了 PE 格式的简化结构。图像中显示的每个头部都被定义为一个数据结构,用于保存有关 PE 文件的信息。

相对虚拟地址 (RVAs)#
相对虚拟地址 (Relative Virtual Addresses, RVAs) 是用于引用 PE 文件内位置的地址。它们用于指定 PE 文件中各种数据结构和节(Section)的位置,例如代码、数据和资源。
RVA 是一个 32 位值,指定了数据结构或节相对于 PE 文件起始位置 的 偏移量 (Offset)。它被称为“相对”地址,因为它指定的是相对于文件开头的偏移量,而不是内存中的绝对地址。这允许同一个文件被加载到内存中的不同地址,而无需更改文件内的 RVA。
💡 初学者提示:RVA 类比
想象一栋公寓楼。
- 基地址 (Base Address) 是这栋楼在街道上的门牌号(比如南京路 100 号)。
- RVA 是你相对于大门的步数(比如在大门内往里走 50 步就是电梯)。
- 虚拟地址 (VA) 是你在街道上的精确坐标。
无论这栋楼盖在南京路还是北京路(基址变化),电梯距离大门的步数(RVA)总是 50 步,保持不变。
要将 RVA 转换为 虚拟地址 (Virtual Address, VA),操作系统会将模块的基地址(模块在内存中加载的位置)加到 RVA 上:
VA = Base Address + RVA
DOS 头 (IMAGE_DOS_HEADER)#
DOS 头位于 PE 文件的开头,包含有关文件的信息,如其大小和特征。最重要的成员是 e_lfanew,它包含了 NT 头的 RVA(偏移量)。
以下代码片段演示了如何检索 DOS 头。
// 指向结构的指针,pPE 是 PE 文件在内存中的起始基地址
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pPE;
// 检查魔术数字是否为 "MZ"
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE){
return -1;
}c由于 DOS 头位于 PE 文件的起始位置,只需将 pPE 变量强制转换为 PIMAGE_DOS_HEADER 即可获得指向该结构的指针。之后进行签名检查以验证 DOS 头是否有效。
NT 头 (IMAGE_NT_HEADERS)#
DOS 头的 e_lfanew 成员是 IMAGE_NT_HEADERS 结构的 RVA。要到达 NT 头,只需将 PE 文件在内存中的基地址加上该偏移量。
// 内存基址 + RVA = 绝对地址 (VA)
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pPE + pImgDosHdr->e_lfanew);
// 检查签名是否为 "PE\0\0"
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE) {
return -1;
}c文件头 (IMAGE_FILE_HEADER)#
文件头是 IMAGE_NT_HEADERS 结构的一个成员,可以通过以下代码访问。
IMAGE_FILE_HEADER ImgFileHdr = pImgNtHdrs->FileHeader;c文件头核心成员:#
NumberOfSections- PE 文件中的节区数量。SizeOfOptionalHeader- 可选头 的大小。Characteristics- 文件的特征,指定它是.exe、.dll还是.sys。
可选头 (IMAGE_OPTIONAL_HEADER)#
可选头也是 IMAGE_NT_HEADERS 结构的成员。
IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader;
// 验证可选头魔数
if (ImgOptHdr.Magic != IMAGE_NT_OPTIONAL_HDR_MAGIC) {
return -1;
}cIMAGE_NT_OPTIONAL_HDR_MAGIC 的值取决于应用是 32 位还是 64 位。如果是 x64 环境,它会自动匹配 64 位的值。
可选头重要成员:#
AddressOfEntryPoint- 程序入口点 (OEP) 的 RVA。ImageBase- PE 文件加载时 首选 的基地址。DataDirectory- 极其重要的成员。这是一个IMAGE_DATA_DIRECTORY数组,包含了导入表、导出表、资源表等重要目录的地址。
数据目录 (IMAGE_DATA_DIRECTORY)#
数据目录数组位于可选头的末尾。每个元素都指向一个特殊的目录结构。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 目录的 RVA
DWORD Size; // 目录的大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;c访问常用的数据目录:#
IMAGE_DIRECTORY_ENTRY_EXPORT(索引 0) - 导出目录。IMAGE_DIRECTORY_ENTRY_IMPORT(索引 1) - 导入目录。IMAGE_DIRECTORY_ENTRY_RESOURCE(索引 2) - 资源目录。
// 获取导出目录信息
IMAGE_DATA_DIRECTORY ExpDataDir = ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];c导出表 (IMAGE_EXPORT_DIRECTORY)#
导出表保存了该 PE 文件对外公开的函数和数据的信息。这在 DLL 文件中最为常见。
导出表重要成员:#
NumberOfFunctions- 导出的函数总数。AddressOfFunctions- 包含函数代码地址(RVA)的数组。AddressOfNames- 包含函数名称字符串地址(RVA)的数组。AddressOfNameOrdinals- 包含函数序号的数组。
// 获取导出表的基地址
PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY)(pPE + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);c导入表 (IMAGE_IMPORT_DESCRIPTOR)#
导入表描述了当前 PE 文件从其他 DLL 中调用的函数。
// 获取第一个导入描述符
IMAGE_IMPORT_DESCRIPTOR* pImgImpDesc = (PIMAGE_IMPORT_DESCRIPTOR)(pPE + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);c导入表实际上是一个数组,最后一个元素全为零,作为结束标志。
PE 节区 (IMAGE_SECTION_HEADER)#
PE 节区(如 .text、.data、.rsrc)紧跟在 NT 头之后。
// 定位第一个节区头
// 节区头紧贴在 NT 头后面
PIMAGE_SECTION_HEADER pImgSectionHdr = (PIMAGE_SECTION_HEADER)(((PBYTE)pImgNtHdrs) + sizeof(IMAGE_NT_HEADERS));c遍历节区:#
通过 pImgNtHdrs->FileHeader.NumberOfSections 获取节区总数,然后进行循环。
PIMAGE_SECTION_HEADER pImgSectionHdr = (PIMAGE_SECTION_HEADER)(((PBYTE)pImgNtHdrs) + sizeof(IMAGE_NT_HEADERS));
for (size_t i = 0; i < pImgNtHdrs->FileHeader.NumberOfSections; i++) {
printf("Section Name: %s\n", pImgSectionHdr->Name);
// 移动到下一个节区头
pImgSectionHdr = (PIMAGE_SECTION_HEADER)((PBYTE)pImgSectionHdr + sizeof(IMAGE_SECTION_HEADER));
}c演示#
本模块分享了一个 PeParser 项目。它可以解析 PE 文件并显示上述所有信息。
💡 注意
要解析 32 位程序,PeParser 必须编译为 32 位;解析 64 位程序则需编译为 64 位。


🎯 总结#
在本模块中,我们学习了:
- RVA 与 VA:理解了偏移量与绝对地址的关系。
- 核心头部结构:掌握了如何从 DOS 头一路“解锁”到节区头。
- 数据交互:通过 C 代码实际访问了这些结构并验证了它们的有效性。
📚 知识扩展
这些手理解析 PE 头部的技能是所有高级恶意软件技术的基础。无论是反射式加载 (Reflective Loading)、进程镂空 (Process Hollowing) 还是导出名混淆 (Export Hashing),其核心逻辑都是在操作这些结构。
📚 下一步学习
下一个模块将介绍 String Hashing。我们将学习如何通过哈希算法隐匿字符串特征,为后续的自定义 API 查找打下基础。