PE文件结构解析:DOS头、NT头、节表、导入表、导出表、资源表详解
说实话,PE文件结构是逆向工程的必修课。我刚开始学逆向那会儿,对着十六进制编辑器看PE文件,感觉就像在看天书。后来踩了不少坑,才慢慢摸清楚门道。今天咱们就把PE文件从头到尾拆一遍,把DOS头、NT头、节表、导入表、导出表、资源表这些核心结构讲透。
1. PE文件整体布局
一个标准的PE文件,从磁盘加载到内存后,结构是这样的:
+-------------------+
| DOS MZ Header | // 所有PE文件的开头
+-------------------+
| DOS Stub | // 兼容老系统用的
+-------------------+
| PE Signature | // "PE\0\0" 四个字节
+-------------------+
| IMAGE_FILE_HEADER | // 文件头
+-------------------+
| IMAGE_OPTIONAL_HEADER | // 可选头(其实必选)
+-------------------+
| Section Table | // 节表,描述各个节
+-------------------+
| Section 1: .text | // 代码节
+-------------------+
| Section 2: .data | // 数据节
+-------------------+
| Section 3: .rdata | // 只读数据节(导入表、导出表都在这里)
+-------------------+
| ... |
+-------------------+
嗯,这里要注意:磁盘上的PE文件和内存中的PE文件,布局基本一致,但有些字段的值会不同。比如节在磁盘上是对齐到文件对齐粒度,在内存中是对齐到页面粒度。我当年做脱壳工具时就因为这个对齐问题折腾了好几天。
2. DOS头详解
每个PE文件都以DOS头开头。为什么?说白了就是为了兼容老系统。在Windows 95之前,大家用的还是DOS系统。PE文件格式设计者留了个心眼:让PE文件在DOS下也能运行——至少能打印一句"This program cannot be run in DOS mode"。
DOS头的结构定义如下:
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // 0x5A4D -> "MZ"
WORD e_cblp; // 文件最后扇区的字节数
WORD e_cp; // 文件中的扇区数
WORD e_crlc; // 重定位项数
WORD e_cparhdr; // 头部大小(以段落为单位)
WORD e_minalloc; // 所需的最小额外段数
WORD e_maxalloc; // 所需的最大额外段数
WORD e_ss; // 初始SS值(DOS相关)
WORD e_sp; // 初始SP值
WORD e_csum; // 校验和
WORD e_ip; // 初始IP值
WORD e_cs; // 初始CS值
WORD e_lfarlc; // 重定位表文件地址
WORD e_ovno; // 覆盖号
WORD e_res[4]; // 保留字
WORD e_oemid; // OEM标识符
WORD e_oeminfo; // OEM信息
WORD e_res2[10]; // 保留字
LONG e_lfanew; // 指向PE签名的文件偏移(关键!)
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
这里最重要的字段就是e_lfanew。它告诉我们在文件中的哪个位置能找到真正的PE头。我见过一些恶意软件会篡改这个字段,把PE头藏到文件中间去,用来躲避静态扫描。嗯,遇到这种情况,你得手动计算偏移。
3. NT头解析
NT头是PE文件的核心。它由三部分组成:PE签名、文件头、可选头。咱们一个一个看。
3.1 PE签名
就是"PE\0\0"四个字节,十六进制是50 45 00 00。这个签名标志着DOS头的结束和NT头的开始。如果这个签名不对,Windows加载器会直接拒绝加载。
3.2 IMAGE_FILE_HEADER
这个结构20个字节,记录了文件的基本信息:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 目标CPU架构
WORD NumberOfSections; // 节的数量
DWORD TimeDateStamp; // 时间戳
DWORD PointerToSymbolTable; // COFF符号表偏移
DWORD NumberOfSymbols; // 符号数量
WORD SizeOfOptionalHeader; // 可选头大小
WORD Characteristics; // 文件属性标志
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
我个人习惯重点关注两个字段:NumberOfSections和Characteristics。前者告诉你文件有多少个节,后者告诉你文件是exe还是dll,是不是系统文件等等。
3.3 IMAGE_OPTIONAL_HEADER
名字叫"可选头",但实际上是必选的。对于32位和64位程序,这个结构的大小不同。关键字段如下:
| 字段 | 说明 | 我的经验 |
|---|---|---|
| Magic | 0x10B (PE32) / 0x20B (PE32+) | 一眼判断是32位还是64位 |
| AddressOfEntryPoint | 程序入口点的RVA | 加壳程序常修改这里 |
| ImageBase | 首选加载基址 | DLL冲突时会被重定位 |
| SectionAlignment | 内存中对齐粒度(通常0x1000) | 和FileAlignment不同时要注意 |
| FileAlignment | 文件中对齐粒度(通常0x200) | 节在文件中的实际大小 |
| SizeOfImage | 内存中整个映像的大小 | 分配内存时用这个值 |
| SizeOfHeaders | 所有头的大小(对齐后) | 第一个节从这里开始 |
| DataDirectory | 数据目录数组(16项) | 导入表、导出表都在这里 |
数据目录是OptionalHeader的最后一部分,它是个数组,每个元素包含一个RVA和大小。索引0是导出表,1是导入表,2是资源表,等等。你想想看,Windows加载器就是通过这个数组找到所有关键数据结构的。
4. 节表详解
节表紧跟在OptionalHeader后面。每个节表项40字节,结构如下:
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之间的映射关系。说白了,你要在内存中访问某个数据,得先通过节表找到它在文件中的位置。
5. 导入表详解
导入表告诉Windows加载器:这个程序需要从哪些DLL导入哪些函数。它位于数据目录索引1的位置。
导入表的结构是一个IMAGE_IMPORT_DESCRIPTOR数组,以全零项结束:
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;
每个导入的DLL对应一个这样的结构。INT和IAT指向两个并行的数组,数组中的每个元素要么是序号(高位为1),要么是指向IMAGE_IMPORT_BY_NAME结构的RVA。
我刚开始逆向时,经常搞混INT和IAT。后来才明白:INT是原始信息,IAT是加载器填充实际地址的地方。加壳程序通常会修改IAT,但INT往往保持不变——这就是我们重建IAT的依据。
6. 导出表详解
导出表是DLL文件才有的(exe也可以有,但很少见)。它告诉系统这个DLL导出了哪些函数,供其他程序调用。导出表位于数据目录索引0。
导出表的结构:
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
DWORD AddressOfNames; // 函数名称表RVA
DWORD AddressOfNameOrdinals; // 序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
这里有三个表:地址表、名称表、序号表。查找一个导出函数的过程是:
- 在名称表中二分查找函数名,得到索引
- 用这个索引去序号表,得到序号
- 用序号减去Base,去地址表拿到函数地址
嗯,这个过程看起来绕,但设计得很巧妙。我做过一个工具,需要动态解析DLL的导出函数,就是按照这个流程来的。有一次遇到一个DLL,它的NumberOfFunctions和NumberOfNames不一致,说明有些函数只有序号没有名字——这种情况在系统DLL里很常见。
7. 资源表详解
资源表存储了图标、对话框、字符串、菜单等资源。它位于数据目录索引2。资源表的结构比较特殊,是一个三层树形结构:
资源目录根节点
├── 类型节点(如RT_ICON、RT_DIALOG)
│ ├── ID节点(具体资源的ID)
│ │ └── 数据节点(实际资源数据)
│ └── ...
└── ...
每个节点都是IMAGE_RESOURCE_DIRECTORY结构,后面跟着一组IMAGE_RESOURCE_DIRECTORY_ENTRY。叶子节点指向实际的数据。
解析资源表是我觉得最头疼的部分。为什么?因为它的结构是自描述的,而且有对齐要求。我当年写资源提取器时,就因为在计算目录项数量时少算了1,导致解析出来的资源全是乱的。
8. 实战:手动解析PE文件
说了这么多理论,咱们来点实际的。下面是一个简单的C代码,演示如何读取PE文件的关键信息:
#include <windows.h>
#include <stdio.h>
void ParsePE(const char* filename) {
HANDLE hFile = CreateFileA(filename, GENERIC_READ,
FILE_SHARE_READ, NULL,
OPEN_EXISTING, 0, NULL);
if (hFile == INVALID_HANDLE_VALUE) return;
HANDLE hMap = CreateFileMapping(hFile, NULL,
PAGE_READONLY, 0, 0, NULL);
if (!hMap) { CloseHandle(hFile); return; }
LPVOID pBase = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
if (!pBase) { CloseHandle(hMap); CloseHandle(hFile); return; }
// 1. 验证DOS头
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pBase;
if (pDos->e_magic != IMAGE_DOS_SIGNATURE) {
printf("不是有效的PE文件\n");
goto cleanup;
}
// 2. 定位NT头
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)
((BYTE*)pBase + pDos->e_lfanew);
if (pNt->Signature != IMAGE_NT_SIGNATURE) {
printf("PE签名错误\n");
goto cleanup;
}
// 3. 读取基本信息
printf("节数量: %d\n", pNt->FileHeader.NumberOfSections);
printf("入口点RVA: 0x%X\n",
pNt->OptionalHeader.AddressOfEntryPoint);
printf("映像基址: 0x%X\n",
pNt->OptionalHeader.ImageBase);
// 4. 遍历节表
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);
for (int i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
printf("节%d: %.8s, VA=0x%X, 文件偏移=0x%X\n",
i, pSection[i].Name,
pSection[i].VirtualAddress,
pSection[i].PointerToRawData);
}
cleanup:
UnmapViewOfFile(pBase);
CloseHandle(hMap);
CloseHandle(hFile);
}
这段代码虽然简单,但涵盖了PE解析的核心流程。我在实际项目中,经常在这个基础上扩展,比如添加导入表解析、导出表解析等功能。
好了,PE文件的核心结构就讲到这里。DOS头、NT头、节表、导入表、导出表、资源表,每个部分都有它的设计初衷和实际用途。你想想看,Windows能运行成千上万的程序,靠的就是这套严谨的结构。下次你遇到一个打不开的exe,不妨用十六进制编辑器看看,说不定就能发现问题的根源。