第四章 PE文件格式解析:DOS头、NT头、节表、导入表、导出表、资源表详解
说实话,PE文件格式是二进制安全领域绕不开的一座山。我当年刚入行时,对着十六进制编辑器看PE结构,看得眼睛都快瞎了。但后来发现,只要你把几个核心结构搞明白,逆向分析Windows程序就像看地图一样清晰。
PE(Portable Executable)是Windows下的可执行文件格式。从EXE到DLL,从SYS驱动到OCX控件,底层都是这套结构。今天我就带你把它拆开看看。
4.1 整体布局:PE文件长什么样?
一个标准的PE文件,从文件头到尾部,大致分为这么几块:
- DOS头 —— 兼容老系统的遗留物,但藏着重要偏移
- DOS存根 —— 没啥用,通常是一段打印“This program cannot be run in DOS mode”的代码
- NT头 —— PE文件的核心,包含文件签名、文件头、可选头
- 节表 —— 描述每个节(.text、.data等)的位置和属性
- 节数据 —— 真正的代码、数据、资源
下面这张图可以帮你建立直观印象:
4.2 DOS头:老古董但必须懂
每个PE文件都以DOS头开头。为什么?因为Windows要兼容DOS时代的加载器。DOS头结构体叫IMAGE_DOS_HEADER,一共64字节。
里面大部分字段都没用了,但有两个你必须记住:
- e_magic (偏移0x00) —— 固定值
0x5A4D,也就是"MZ"。看到这两个字符,就知道这是个PE文件。 - e_lfanew (偏移0x3C) —— 指向NT头的文件偏移。这是整个解析的起点。
// DOS头结构体
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // 0x5A4D "MZ"
WORD e_cblp; // 已废弃
WORD e_cp; // 已废弃
// ... 中间一堆废弃字段 ...
LONG e_lfanew; // 关键!NT头偏移
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
4.3 NT头:PE文件的心脏
NT头由三部分组成:签名、文件头、可选头。结构体是IMAGE_NT_HEADERS。
4.3.1 Signature
4字节,固定值0x00004550,也就是"PE\0\0"。看到这个,才确认是PE文件。
4.3.2 FileHeader
20字节,描述文件的基本信息。关键字段:
| 字段 | 大小 | 说明 |
|---|---|---|
| Machine | 2字节 | CPU架构。0x014C是x86,0x8664是x64 |
| NumberOfSections | 2字节 | 节的数量。解析节表时要用 |
| SizeOfOptionalHeader | 2字节 | 可选头大小。x86是0xE0,x64是0xF0 |
| Characteristics | 2字节 | 文件属性。0x0002是可执行,0x2000是DLL |
4.3.3 OptionalHeader
名字叫"可选",实际上必须存在。它包含加载器需要的所有信息。关键字段:
- Magic —— 0x10B是PE32,0x20B是PE32+
- AddressOfEntryPoint —— 程序入口点的RVA(相对虚拟地址)
- ImageBase —— 加载时的首选基址。EXE通常是0x00400000,DLL是0x10000000
- SectionAlignment —— 内存中对齐粒度,通常是0x1000
- FileAlignment —— 文件中对齐粒度,通常是0x200
- SizeOfImage —— 加载后整个镜像的大小
- SizeOfHeaders —— 所有头的大小,节数据从这里开始
- DataDirectory —— 数据目录数组,指向导入表、导出表等
重点:DataDirectory是解析导入表、导出表、资源表的入口。它是一个数组,每个元素16字节(8字节RVA + 8字节大小)。数组索引从0开始:
- 索引0:导出表
- 索引1:导入表
- 索引2:资源表
- 索引3:异常表
- 索引4:安全表
- 索引5:重定位表
- …… 一共16项
4.4 节表:文件到内存的映射
节表紧跟在NT头之后。每个节描述符40字节,结构体是IMAGE_SECTION_HEADER。节的数量由FileHeader.NumberOfSections决定。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[8]; // 节名,如".text"
union {
DWORD PhysicalAddress;
DWORD VirtualSize; // 内存中节的大小
} Misc;
DWORD VirtualAddress; // 内存中节的RVA
DWORD SizeOfRawData; // 文件中节的大小
DWORD PointerToRawData;// 文件中节的偏移
DWORD PointerToRelocations; // 重定位偏移
DWORD PointerToLinenumbers; // 行号偏移
WORD NumberOfRelocations; // 重定位项数
WORD NumberOfLinenumbers; // 行号项数
DWORD Characteristics; // 节属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
解析节表时,我最关注的是VirtualAddress和PointerToRawData。这两个字段实现了RVA到文件偏移的转换。公式很简单:
// RVA转文件偏移
// 遍历节表,找到包含该RVA的节
// 然后:FileOffset = RVA - Section.VirtualAddress + Section.PointerToRawData
DWORD RvaToOffset(PIMAGE_NT_HEADERS nt, DWORD rva) {
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (rva >= section[i].VirtualAddress &&
rva < section[i].VirtualAddress + section[i].SizeOfRawData) {
return rva - section[i].VirtualAddress + section[i].PointerToRawData;
}
}
return 0; // 没找到
}
4.5 导入表:程序调用了哪些外部函数?
导入表描述了PE文件从其他DLL中导入了哪些函数。它在DataDirectory索引1处。
导入表是一个IMAGE_IMPORT_DESCRIPTOR数组,以全零项结束。每个描述符20字节:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; // INT (Import Name Table) RVA
};
DWORD TimeDateStamp; // 时间戳
DWORD ForwarderChain; // 转发链
DWORD Name; // DLL名称的RVA
DWORD FirstThunk; // IAT (Import Address Table) RVA
} IMAGE_IMPORT_DESCRIPTOR;
解析导入表的核心思路:
- 找到DataDirectory[1]的RVA,转成文件偏移
- 遍历IMAGE_IMPORT_DESCRIPTOR数组,直到遇到全零项
- 对每个描述符,读取Name字段得到DLL名称
- 遍历OriginalFirstThunk指向的INT数组,每个元素是一个IMAGE_THUNK_DATA
- 如果IMAGE_THUNK_DATA的最高位为1,说明是按序号导入;否则指向IMAGE_IMPORT_BY_NAME结构
// 解析导入表示例
void ParseImportTable(PIMAGE_NT_HEADERS nt, BYTE* fileData) {
PIMAGE_DATA_DIRECTORY importDir = &nt->OptionalHeader.DataDirectory[1];
DWORD importRva = importDir->VirtualAddress;
DWORD importOffset = RvaToOffset(nt, importRva);
PIMAGE_IMPORT_DESCRIPTOR importDesc =
(PIMAGE_IMPORT_DESCRIPTOR)(fileData + importOffset);
while (importDesc->Name != 0) {
// 获取DLL名称
char* dllName = (char*)(fileData + RvaToOffset(nt, importDesc->Name));
printf("DLL: %s\n", dllName);
// 遍历导入函数
PIMAGE_THUNK_DATA thunk =
(PIMAGE_THUNK_DATA)(fileData + RvaToOffset(nt, importDesc->OriginalFirstThunk));
while (thunk->u1.AddressOfData != 0) {
if (thunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) {
// 按序号导入
WORD ordinal = thunk->u1.Ordinal & 0xFFFF;
printf(" 序号: %d\n", ordinal);
} else {
// 按名称导入
PIMAGE_IMPORT_BY_NAME importByName =
(PIMAGE_IMPORT_BY_NAME)(fileData + RvaToOffset(nt, thunk->u1.AddressOfData));
printf(" 函数: %s\n", importByName->Name);
}
thunk++;
}
importDesc++;
}
}
实战经验:我在分析恶意软件时,经常遇到导入表被混淆的情况。有些样本会把IAT(FirstThunk)指向一个无效地址,但INT(OriginalFirstThunk)却是正确的。这时候一定要用INT来解析,不要信IAT。另外,有些壳会动态解析导入函数,这时候静态导入表可能是空的,需要动态调试才能看到真正的导入。
4.6 导出表:DLL提供了哪些函数?
导出表是DLL文件的标配,EXE一般没有。它在DataDirectory索引0处。
导出表结构体是IMAGE_EXPORT_DIRECTORY:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; // 未使用
DWORD TimeDateStamp; // 时间戳
WORD MajorVersion; // 主版本
WORD MinorVersion; // 次版本
DWORD Name; // DLL名称的RVA
DWORD Base; // 序号基数
DWORD NumberOfFunctions; // 导出函数总数
DWORD NumberOfNames; // 按名称导出的函数数
DWORD AddressOfFunctions; // 函数地址表RVA (EAT)
DWORD AddressOfNames; // 函数名称表RVA (ENT)
DWORD AddressOfNameOrdinals; // 序号表RVA (EOT)
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
解析导出表时,有三个表需要配合使用:
- AddressOfFunctions (EAT) —— 数组,每个元素4字节,是函数的RVA。数组大小是NumberOfFunctions
- AddressOfNames (ENT) —— 数组,每个元素4字节,是函数名称字符串的RVA。数组大小是NumberOfNames
- AddressOfNameOrdinals (EOT) —— 数组,每个元素2字节,是序号。数组大小是NumberOfNames
按名称查找函数的流程:
- 在ENT中二分查找函数名,得到索引i
- 用索引i从EOT中取出序号ordinal = EOT[i]
- 用序号ordinal从EAT中取出函数地址RVA = EAT[ordinal]
4.7 资源表:图标、对话框、字符串都在这里
资源表在DataDirectory索引2处。它的结构比导入导出表复杂得多,是一个三层树形结构。
资源表的核心结构体:
- IMAGE_RESOURCE_DIRECTORY —— 目录节点,包含子项数量和类型
- IMAGE_RESOURCE_DIRECTORY_ENTRY —— 目录项,指向下一层或数据
- IMAGE_RESOURCE_DATA_ENTRY —— 数据节点,包含资源的实际位置和大小
三层结构分别是:
- 第一层:资源类型 —— RT_ICON (3)、RT_DIALOG (5)、RT_STRING (6)、RT_MENU (4) 等
- 第二层:资源ID —— 每个类型下的具体资源编号
- 第三层:语言ID —— 不同语言版本,如中文(2052)、英文(1033)
// 资源表遍历示例(简化版)
void ParseResourceTable(PIMAGE_NT_HEADERS nt, BYTE* fileData) {
PIMAGE_DATA_DIRECTORY resDir = &nt->OptionalHeader.DataDirectory[2];
DWORD resRva = resDir->VirtualAddress;
DWORD resOffset = RvaToOffset(nt, resRva);
PIMAGE_RESOURCE_DIRECTORY rootDir =
(PIMAGE_RESOURCE_DIRECTORY)(fileData + resOffset);
// 遍历第一层(资源类型)
PIMAGE_RESOURCE_DIRECTORY_ENTRY entries =
(PIMAGE_RESOURCE_DIRECTORY_ENTRY)(rootDir + 1);
for (int i = 0; i < rootDir->NumberOfNamedEntries + rootDir->NumberOfIdEntries; i++) {
if (entries[i].DataIsDirectory) {
// 进入下一层
DWORD subDirRva = entries[i].OffsetToDirectory;
// ... 递归解析
} else {
// 获取数据
PIMAGE_RESOURCE_DATA_ENTRY dataEntry =
(PIMAGE_RESOURCE_DATA_ENTRY)(fileData + RvaToOffset(nt, entries[i].OffsetToData));
// dataEntry->OffsetToData 是资源数据的RVA
// dataEntry->Size 是资源数据的大小
}
}
}
4.8 实战:用Python解析PE文件
理论讲完了,我们来点实际的。下面是一个用Python解析PE文件核心结构的脚本:
import struct
def parse_pe(filepath):
with open(filepath, 'rb') as f:
data = f.read()
# 1. 验证DOS头
if data[0:2] != b'MZ':
print("不是有效的PE文件")
return
# 2. 获取NT头偏移
e_lfanew = struct.unpack('
总结一下:PE文件解析是逆向工程的必修课。DOS头告诉你NT头在哪,NT头告诉你节表和导入导出表在哪,节表告诉你代码和数据在哪。这套结构从Windows 95用到现在,二十多年没变过。掌握了它,你就能看懂任何Windows程序的内存布局。
我个人建议,初学者可以先用十六进制编辑器打开一个简单的EXE,对照着结构体定义,一个一个字段去验证。这个过程虽然枯燥,但效果非常好。我当年就是这么过来的。