5、PE文件格式深度解析:DOS头与NT头、节表与节区、导入表与导出表、资源表分析

PE文件,说白了就是Windows下可执行文件的“身份证”。你看到的exe、dll、sys,底层都套着PE这件马甲。我最早接触PE时,觉得它就是个复杂的结构体堆叠。后来逆向一个加壳样本,被DOS头里的“MZ”两个字母坑了一晚上——嗯,从那以后我再也不敢小看这堆字节了。

今天咱们就把PE的骨架拆开看。从DOS头一路摸到资源表,每个节点我都会带上实际项目里的坑和技巧。你想想看,搞懂了PE,你就能手动修改导入表、注入代码、甚至修复被破坏的节表——这不比只会用工具爽多了?

PE文件结构总览 DOS头 (IMAGE_DOS_HEADER) DOS Stub (16位残留) NT头 (IMAGE_NT_HEADERS) 节表 (IMAGE_SECTION_HEADER) 节区 (.text .data .rdata) 导入表 (IMAGE_IMPORT_DESCRIPTOR) 导出表 (IMAGE_EXPORT_DIRECTORY) 资源表 (IMAGE_RESOURCE_DIRECTORY) 每个表都对应一个结构体,通过RVA(相对虚拟地址)在内存中定位 节表描述节区的属性、大小、在文件与内存中的偏移 导入/导出/资源表是数据目录表的前三个条目

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加载到内存时,节区需要按页对齐,而文件中对齐粒度小。节表就是做这个映射的。我见过一些加壳工具把节表项的数量改大,导致解析器读到了假节区——这是反调试的常见手法。

字段说明个人经验
Name8字节节名,如.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条目结束。每个条目里有OriginalFirstThunkFirstThunk两个指针,它们都指向一个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里的OffsetToDataSize给出的。但OffsetToData是RVA,需要转换成文件偏移。我见过一些工具直接拿RVA当文件偏移用,结果读出来的数据全是乱的。

资源表还有一个坑:它的目录条目里的NameIsString标志。如果该标志为1,那么NameOffset指向一个IMAGE_RESOURCE_DIR_STRING_U结构(Unicode字符串)。否则,NameOffset的低31位就是整数ID。我早期没注意这个标志,把ID当字符串指针用,程序直接崩了。

嗯,PE文件格式说到底就是一堆结构体的组合。但每个字段背后都有设计者的权衡,也有攻击者的利用。你只要多动手解析几个样本,这些结构就会变成你的肌肉记忆。

总结一下我个人的核心建议:

  • 解析任何PE前,先验证e_lfanewNumberOfSections的合法性
  • 处理导入表时,同时检查OriginalFirstThunkFirstThunk
  • 资源表解析一定要处理Unicode名称和整数ID两种情况
  • 所有RVA转文件偏移时,记得遍历节表找到正确的节区

搞懂了这些,你就能手动修改PE文件了。比如给一个exe增加一个导入函数,或者修改资源里的版本信息——这些操作在逆向工程和软件维护中非常实用。

公众号:蓝海资料掘金营,微信deep3321