5、PE文件格式深度解析:DOS头与NT头、节表与节区、导入表与导出表、资源表分析
PE文件,说白了就是Windows下可执行文件的“身份证”。你看到的exe、dll、sys,底层都套着PE这件马甲。我最早接触PE时,觉得它就是个复杂的结构体堆叠。后来逆向一个加壳样本,被DOS头里的“MZ”两个字母坑了一晚上——嗯,从那以后我再也不敢小看这堆字节了。
今天咱们就把PE的骨架拆开看。从DOS头一路摸到资源表,每个节点我都会带上实际项目里的坑和技巧。你想想看,搞懂了PE,你就能手动修改导入表、注入代码、甚至修复被破坏的节表——这不比只会用工具爽多了?
5.1 DOS头与NT头——PE的起点
每个PE文件开头两个字节一定是4D 5A,也就是“MZ”。这是DOS头的签名。我见过有人直接跳过这64字节去解析NT头,结果在畸形文件上翻车了——因为DOS头里的e_lfanew字段才真正告诉你NT头在哪。
关键字段:
e_magic:WORD,固定0x5A4D(MZ)e_lfanew:DWORD,NT头的文件偏移(非常重要!)
我个人习惯拿到PE文件后,先读e_lfanew的值,然后直接跳到那个位置。NT头由三部分组成:签名(PE\0\0)、文件头(IMAGE_FILE_HEADER)、可选头(IMAGE_OPTIONAL_HEADER)。
避坑指南: 我曾经遇到一个样本,它的e_lfanew指向了一个无效区域。原因是文件被截断,但DOS头没被破坏。如果你只校验MZ就认为文件合法,后面解析必崩。所以一定要检查e_lfanew是否在文件长度范围内。
可选头里的Magic字段区分32位(0x10B)和64位(0x20B)。我早期写解析器时忘了判断这个,直接把32位结构体套在64位PE上,结果所有RVA都算错了——嗯,那次调试到凌晨三点。
5.2 节表与节区——PE的骨架
节表紧跟在NT头之后。每个节表项占40字节,描述一个节区的名称、虚拟大小、虚拟地址、文件偏移、大小、属性等。节区才是真正存放代码、数据的地方。
你想想看,为什么要有节表?因为PE加载到内存时,节区需要按页对齐,而文件中对齐粒度小。节表就是做这个映射的。我见过一些加壳工具把节表项的数量改大,导致解析器读到了假节区——这是反调试的常见手法。
| 字段 | 说明 | 个人经验 |
|---|---|---|
| Name | 8字节节名,如.text | 其实节名可以随便写,系统不依赖它 |
| VirtualSize | 内存中节区大小 | 如果比SizeOfRawData大,多出的部分在内存中填0 |
| VirtualAddress | 节区在内存中的RVA | 计算RVA时别忘了加上ImageBase |
| SizeOfRawData | 文件中节区大小 | 我遇到过文件大小小于这个值的情况——文件被截断了 |
| PointerToRawData | 节区在文件中的偏移 | 必须对齐到FileAlignment |
| Characteristics | 属性(可读/写/执行) | 代码节通常是0x60000020 |
注意: 节表项的数量由IMAGE_FILE_HEADER.NumberOfSections决定。但有些恶意软件会把这个值改得很大,导致解析器越界读取。我建议你实际解析时,最多只信任前20个节表项,后面的直接忽略。
5.3 导入表与导出表——PE的“外交”接口
导入表告诉系统:这个PE需要哪些DLL里的哪些函数。导出表则相反:这个DLL提供了哪些函数给别人用。我逆向时最常动刀的就是导入表——注入代码、劫持API都靠它。
导入表是一个IMAGE_IMPORT_DESCRIPTOR数组,每个条目对应一个DLL。数组以全0条目结束。每个条目里有OriginalFirstThunk和FirstThunk两个指针,它们都指向一个IMAGE_THUNK_DATA数组。说白了,这两个数组最终都指向函数名或序号。
核心逻辑:
- 如果
IMAGE_THUNK_DATA的最高位为1,则低31位是函数序号(按序号导入) - 否则,它指向一个
IMAGE_IMPORT_BY_NAME结构,里面是函数名 - 系统加载时会把
FirstThunk数组里的值替换成函数真实地址——这就是IAT(导入地址表)
我个人习惯在解析导入表时,先检查OriginalFirstThunk是否为0。如果为0,就用FirstThunk作为原始指针。有些编译器会省略OriginalFirstThunk,但系统依然能工作——嗯,这算是个历史遗留问题。
导出表的结构稍微简单些。它包含函数地址表、函数名表、序号表。我曾在分析一个DLL时发现,它的导出函数名表被篡改了,但地址表是正常的。通过序号调用依然能工作,但用GetProcAddress查名字就失败。这种手法常用于隐藏导出函数。
避坑指南: 我曾经写一个导入表修复工具,发现有些PE的FirstThunk指向的IAT不在任何节区内,而是在文件头附近。这其实是合法的,因为IAT可以放在任何可读写的区域。但如果你按常规思路只扫描节区,就会漏掉它。
5.4 资源表——PE的“百宝箱”
资源表是PE里最复杂的目录之一。它采用三层目录结构:类型、名称、语言。每个节点要么是目录条目(IMAGE_RESOURCE_DIRECTORY_ENTRY),要么是数据条目(IMAGE_RESOURCE_DATA_ENTRY)。
我最早解析资源表时,被它的递归结构绕晕了。后来我画了个树状图才理清:根目录下是类型(比如RT_ICON、RT_DIALOG),每个类型下是名称(可以是ID或字符串),每个名称下是语言(比如中文、英文)。叶子节点才指向实际数据。
举个例子,你想提取一个exe里的图标:
// 伪代码示意
root = 资源目录基址
for each 类型条目:
if 类型 == RT_ICON:
进入名称目录
for each 名称条目:
进入语言目录
读取数据条目 -> 拿到偏移和大小
从文件/内存中拷贝图标数据
注意: 资源数据在文件中的偏移和大小,是通过IMAGE_RESOURCE_DATA_ENTRY里的OffsetToData和Size给出的。但OffsetToData是RVA,需要转换成文件偏移。我见过一些工具直接拿RVA当文件偏移用,结果读出来的数据全是乱的。
资源表还有一个坑:它的目录条目里的NameIsString标志。如果该标志为1,那么NameOffset指向一个IMAGE_RESOURCE_DIR_STRING_U结构(Unicode字符串)。否则,NameOffset的低31位就是整数ID。我早期没注意这个标志,把ID当字符串指针用,程序直接崩了。
嗯,PE文件格式说到底就是一堆结构体的组合。但每个字段背后都有设计者的权衡,也有攻击者的利用。你只要多动手解析几个样本,这些结构就会变成你的肌肉记忆。
总结一下我个人的核心建议:
- 解析任何PE前,先验证
e_lfanew和NumberOfSections的合法性 - 处理导入表时,同时检查
OriginalFirstThunk和FirstThunk - 资源表解析一定要处理Unicode名称和整数ID两种情况
- 所有RVA转文件偏移时,记得遍历节表找到正确的节区
搞懂了这些,你就能手动修改PE文件了。比如给一个exe增加一个导入函数,或者修改资源里的版本信息——这些操作在逆向工程和软件维护中非常实用。