0%
毅种循环

模块 50 - 解析 PE 头 (PE Headers)

恶意软件开发课程 - 解析 PE 头 (PE Headers)


模块 50 - 解析 PE 头 (PE Headers)#

[!IMPORTANT] 本知识库声明

本知识库由本人整理自互联网 MalDev Academy 泄露资源,并由本人手动翻译为中文,过程中增加了大量关键技术提示实践心得

  1. 内容完整性:并未修改任何核心代码与技术逻辑,仅做汉化与注释加强。
  2. 版权归属:原始知识产权归原作者/官方所有。
  3. 支持正版:本仓库仅供内部学习交流,如果您有经济能力,请务必支持正版课程
  4. 权利申诉:如相关内容侵犯了您的权益,请联系我,我将立即核实并删除。

解析 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;
}
c

IMAGE_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 位。

图片
图片

图片
图片


🎯 总结#

在本模块中,我们学习了:

  1. RVA 与 VA:理解了偏移量与绝对地址的关系。
  2. 核心头部结构:掌握了如何从 DOS 头一路“解锁”到节区头。
  3. 数据交互:通过 C 代码实际访问了这些结构并验证了它们的有效性。

📚 知识扩展

这些手理解析 PE 头部的技能是所有高级恶意软件技术的基础。无论是反射式加载 (Reflective Loading)进程镂空 (Process Hollowing) 还是导出名混淆 (Export Hashing),其核心逻辑都是在操作这些结构。

📚 下一步学习

下一个模块将介绍 String Hashing。我们将学习如何通过哈希算法隐匿字符串特征,为后续的自定义 API 查找打下基础。