PE文件结构:从DOS头到重定位表
PE文件格式,说白了就是Windows下可执行文件的“身份证”。我刚开始逆向那会儿,觉得这东西又臭又长,直到有一次分析一个加壳样本,发现它把导入表全改了——嗯,从那以后我再也不敢跳过PE结构了。
这一章我们聚焦PE文件的核心骨架:DOS头、NT头、节表、导入导出表、资源节和重定位表。你把这些吃透了,后面分析恶意样本、定位漏洞才能游刃有余。
核心观点:PE文件本质上是一个“目录+数据”的结构。头部告诉你数据在哪、有多大、怎么用;节区才是真正的代码和数据。理解了这个,你就抓住了PE的命门。
3.1 DOS头与NT头:PE文件的“双头怪”
每个PE文件都以一个古老的DOS头开头。为什么?因为Windows要兼容DOS时代。我见过不少新手直接跳过DOS头,结果在解析NT头时偏移算错了——这坑我踩过。
3.1.1 DOS头结构
DOS头只有两个字段对我们有用:
- e_magic:固定为0x5A4D("MZ"),标识这是个PE文件。
- e_lfanew:指向NT头的偏移量(文件偏移)。
其他字段都是DOS时代的遗产,逆向时基本不用管。你只需要从文件开头读e_lfanew,就能跳到NT头。
// DOS头结构(简化版)
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // 0x5A4D "MZ"
WORD e_cblp; // 忽略
// ... 中间一堆废弃字段 ...
LONG e_lfanew; // NT头偏移(关键!)
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
个人习惯:我解析PE时,第一步永远是校验e_magic是否为"MZ",然后读e_lfanew。如果e_lfanew指向的位置不是"PE\0\0",那文件大概率被篡改了。
3.1.2 NT头结构
NT头是PE文件的核心,包含三个部分:
| 字段 | 大小 | 说明 |
|---|---|---|
| Signature | 4字节 | 固定"PE\0\0"(0x00004550) |
| FileHeader | 20字节 | 文件基本信息(机器类型、节数量等) |
| OptionalHeader | 可变(32位224字节,64位240字节) | 加载器所需的关键信息 |
OptionalHeader里有个字段特别重要——DataDirectory(数据目录)。它是个数组,每个元素指向一个关键表(导入表、导出表、资源表等)。说白了,PE文件的所有“索引”都靠它。
// NT头结构
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature; // "PE\0\0"
IMAGE_FILE_HEADER FileHeader; // 文件头
IMAGE_OPTIONAL_HEADER64 OptionalHeader; // 可选头
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
// 数据目录项
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 虚拟地址(RVA)
DWORD Size; // 大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
避坑指南:我曾经在分析一个64位样本时,直接用32位的OptionalHeader大小去解析,结果后面所有字段都读错了。记住:32位和64位的OptionalHeader大小不同,一定要根据FileHeader.Machine字段判断。
3.2 节表与节:PE文件的“分区”
节(Section)是PE文件里真正存放代码和数据的地方。节表(Section Table)就是这些节的“目录”。你想想看,一个文件里可能有代码节、数据节、资源节,操作系统怎么知道哪个是哪个?靠节表。
3.2.1 节表结构
每个节表项40字节,结构如下:
| 字段 | 大小 | 说明 |
|---|---|---|
| Name | 8字节 | 节名称(如".text"、".data") |
| VirtualSize | 4字节 | 内存中节的大小 |
| VirtualAddress | 4字节 | 内存中节的起始RVA |
| SizeOfRawData | 4字节 | 文件中节的大小 |
| PointerToRawData | 4字节 | 文件中节的偏移 |
| Characteristics | 4字节 | 属性(可读/可写/可执行) |
这里有个关键点:VirtualSize和SizeOfRawData可能不一样。为什么?因为内存对齐和文件对齐的粒度不同。我见过有人直接用文件大小去申请内存,结果程序跑不起来——对齐没处理好。
3.2.2 常见节及其用途
- .text:代码节,可执行不可写。逆向分析的主战场。
- .data / .rdata:数据节,存放全局变量和只读数据。
- .rsrc:资源节,存放图标、字符串、对话框等。
- .reloc:重定位节,用于ASLR(地址随机化)。
- .idata:导入表节,存放DLL和函数信息。
我的经验:分析恶意软件时,我习惯先看节表。如果发现一个节的名字很奇怪(比如"UPX0"、".packed"),或者节的Characteristics同时有可读可写可执行,那基本可以确定是加壳了。
3.3 导入表(IAT)与导出表(EAT)
这两个表是PE文件与外部世界通信的桥梁。导入表告诉系统“我需要哪些DLL和函数”,导出表告诉系统“我能提供哪些函数”。
3.3.1 导入表(IAT)
导入表的结构有点绕,但核心思路很简单:
- 每个被导入的DLL对应一个IMAGE_IMPORT_DESCRIPTOR结构。
- 这个结构里有一个OriginalFirstThunk(INT)和一个FirstThunk(IAT)。
- INT和IAT都指向一个函数名/序号数组。加载前,它们指向函数名;加载后,IAT被替换为函数实际地址。
// 导入描述符
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0表示结束
DWORD OriginalFirstThunk; // INT(导入名称表)
};
DWORD TimeDateStamp; // 时间戳
DWORD ForwarderChain; // 转发链
DWORD Name; // DLL名称的RVA
DWORD FirstThunk; // IAT(导入地址表)
} IMAGE_IMPORT_DESCRIPTOR;
实战要点:我在分析一个Rootkit时,发现它的IAT被HOOK了——FirstThunk指向的地址不是系统API,而是恶意代码。检测这种HOOK的方法很简单:对比INT和IAT的值,如果不一样,说明被篡改了。
3.3.2 导出表(EAT)
导出表是DLL用来暴露函数的。结构如下:
| 字段 | 说明 |
|---|---|
| NumberOfFunctions | 导出函数总数 |
| NumberOfNames | 有名函数数量 |
| AddressOfFunctions | 函数地址数组的RVA |
| AddressOfNames | 函数名称数组的RVA |
| AddressOfNameOrdinals | 序号数组的RVA(名称→序号映射) |
查找导出函数的流程:通过名称→序号→地址。说白了就是三层跳转。
我曾经踩过的坑:解析导出表时,如果AddressOfFunctions和AddressOfNames的差值不对,或者序号数组越界,那这个DLL可能被手动修改过。恶意软件经常用这种手法隐藏导出函数。
3.4 资源节(.rsrc)与重定位表(.reloc)
这两个节平时不太起眼,但在漏洞分析和逆向工程中经常用到。
3.4.1 资源节(.rsrc)
资源节采用三层目录结构:
- 第一层:资源类型(图标、字符串、对话框等)
- 第二层:资源ID(每个类型下的具体资源)
- 第三层:语言(不同语言的版本)
每个节点都是一个IMAGE_RESOURCE_DIRECTORY_ENTRY结构,指向下一层或实际数据。
// 资源目录项
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31; // 名称偏移
DWORD NameIsString:1; // 是否为字符串名称
};
DWORD Name; // 整数ID
};
union {
DWORD OffsetToData; // 指向数据或子目录
DWORD DataIsDirectory; // 是否为子目录
};
} IMAGE_RESOURCE_DIRECTORY_ENTRY;
实用技巧:我分析恶意文档时,经常从资源节提取嵌入的PE文件或脚本。用Resource Hacker之类的工具可以快速浏览,但手动解析能发现隐藏的资源——有些恶意软件会把资源放在“无效”类型里。
3.4.2 重定位表(.reloc)
重定位表用于ASLR(地址空间布局随机化)。当系统加载PE文件时,如果首选基址被占用,加载器会根据重定位表修改代码中的绝对地址。
重定位表的结构:
- 每个IMAGE_BASE_RELOCATION结构表示一个4KB页的重定位信息。
- 结构包含VirtualAddress(页基址)和SizeOfBlock(块大小)。
- 后面跟着一系列TypeOffset(2字节),高4位是类型,低12位是偏移。
// 重定位块
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 页RVA
DWORD SizeOfBlock; // 块大小(包含本结构)
// WORD TypeOffset[]; // 后面跟着的偏移项
} IMAGE_BASE_RELOCATION;
漏洞分析视角:重定位表在漏洞利用中很关键。比如,如果你要写一个exploit,需要知道目标进程的基址,重定位表能帮你找到所有需要修正的地址点。我在分析一个堆溢出漏洞时,就是通过重定位表定位到了关键的函数指针。
3.5 PE结构总览图
下面这张图把PE文件的核心结构串起来了。你对照着看,能更直观地理解各部分的关系。
3.6 本章小结
PE文件结构是二进制逆向的基石。DOS头和NT头是入口,节表是索引,导入导出表是通信接口,资源节和重定位表是辅助数据。你把这些搞清楚了,后面分析加壳、HOOK、漏洞利用才能得心应手。
最后说一句:我建议你找个真实的PE文件,用010 Editor或者CFF Explorer对照着看一遍。光看书不实践,永远记不住。嗯,动手吧。