3、PE文件结构解析:PE文件格式概述、DOS头与NT头、节表、导入表与导出表、资源节
说到PE文件,我估计很多搞逆向的朋友第一次接触时都有点头大。一堆结构体、偏移量、标志位……说实话,我当年刚学的时候也懵。但后来我发现,PE结构其实就像一栋楼的建筑图纸——你只要看懂几个关键承重墙,整栋楼的结构就清晰了。
PE,全称Portable Executable,是Windows下可执行文件的标准格式。EXE、DLL、SYS,甚至OCX控件,底层都是这套结构。说白了,它就是Windows加载器用来读懂你程序的那张「说明书」。
3.1 PE文件格式概述
一个典型的PE文件,从磁盘到内存,大致长这样:
+-------------------+
| DOS MZ Header | -> 所有PE文件的开头,标志是 "MZ"
+-------------------+
| DOS Stub | -> 一段小程序,通常打印 "This program cannot be run in DOS mode"
+-------------------+
| PE Signature | -> "PE\0\0",4字节
+-------------------+
| IMAGE_FILE_HEADER | -> 文件头,描述CPU、节数量等
+-------------------+
| IMAGE_OPTIONAL_HEADER | -> 可选头,包含入口点、镜像基址等关键信息
+-------------------+
| Section Table | -> 节表,描述每个节(.text, .data, .rsrc等)的位置和属性
+-------------------+
| Section 1: .text | -> 代码节
| Section 2: .data | -> 数据节
| Section 3: .rsrc | -> 资源节
| ... |
+-------------------+
嗯,这里要注意:PE文件在磁盘上的布局和加载到内存后的布局并不完全一样。节的对齐方式不同,磁盘上是FileAlignment(通常0x200),内存里是SectionAlignment(通常0x1000)。我早期调试一个加壳样本时,就因为没注意这个对齐差异,算偏移算错了整整一下午……
3.2 DOS头与NT头
每个PE文件都以一个DOS头开始。为什么?历史原因。当年Windows还跑在DOS之上,为了兼容,所有PE文件前面都保留了一个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值(相对偏移)
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; // NT头的文件偏移量!关键字段
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
这个结构里,99%的字段我们平时根本用不上。唯一必须记住的,就是最后一个字段 e_lfanew。它指向真正的NT头起始位置。我见过一些恶意软件会篡改这个字段,把NT头藏到文件深处,用来躲避静态扫描。你想想看,如果加载器读到的e_lfanew指向一个错误位置,那整个解析就全乱了。
顺着e_lfanew找到的,就是NT头。NT头由三部分组成:
- PE Signature:4字节,固定为 "PE\0\0" (0x00004550)
- IMAGE_FILE_HEADER:文件头,20字节
- IMAGE_OPTIONAL_HEADER:可选头,大小可变(但PE32+和PE32不同)
文件头里我最常看的是 NumberOfSections 和 SizeOfOptionalHeader。前者告诉你这个PE文件有多少个节,后者告诉你可选头有多大。为什么重要?因为节表紧跟在可选头后面,如果你不知道可选头大小,你就不知道节表从哪开始。
3.3 节表(Section Table)
节表是PE文件的「目录」。它告诉加载器:每个节叫什么名字、在文件里占多大、加载到内存后应该放在哪、有什么权限。
每个节表项的结构如下:
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_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;
常见的节名和用途:
| 节名 | 用途 | 典型属性 |
|---|---|---|
| .text | 代码节,存放可执行指令 | 可读 + 可执行 |
| .data | 已初始化的全局变量 | 可读 + 可写 |
| .rdata | 只读数据,如导入表、导出表、常量 | 可读 |
| .rsrc | 资源节,存放图标、字符串、对话框等 | 可读 |
| .reloc | 基址重定位表 | 可读 + 可丢弃 |
这里有个坑:节名其实不重要。Windows加载器根本不看节名,它只看 Characteristics 属性。你完全可以把代码放在一个叫 .data 的节里,只要它的属性是 可执行,CPU照样跑。我曾经分析过一个恶意DLL,它的代码节就叫 .abc,属性设成了可读可写可执行,加载器照样正常加载。所以,别迷信节名。
SizeOfRawData 和 VirtualSize 是否合理。有些加壳工具会把 SizeOfRawData 设成0,但 VirtualSize 非0,表示这个节在磁盘上不存在,只在内存中展开。如果你按磁盘偏移去读,就会读到空数据。
3.4 导入表(IAT)与导出表(EDT)
这两个表,是逆向分析中最常打交道的东西。说白了,导入表告诉Windows:「我这个程序需要调用哪些DLL里的哪些函数」。导出表则相反:「我这个DLL提供了哪些函数给别人用」。
导入表的结构是一个数组,每个元素指向一个 IMAGE_IMPORT_DESCRIPTOR:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 通常指向INT(Import Name Table)
DWORD OriginalFirstThunk; // 指向INT的RVA
};
DWORD TimeDateStamp; // 时间戳,通常为0
DWORD ForwarderChain; // 转发链,通常为-1
DWORD Name; // DLL名称的RVA
DWORD FirstThunk; // 指向IAT(Import Address Table)的RVA
} IMAGE_IMPORT_DESCRIPTOR;
这里有两个关键字段:OriginalFirstThunk(INT)和 FirstThunk(IAT)。它们都指向一个由 IMAGE_THUNK_DATA 组成的数组。每个 IMAGE_THUNK_DATA 要么是一个序号(高位为1),要么是一个指向 IMAGE_IMPORT_BY_NAME 的RVA。
我个人的理解是:INT是「原始」的导入信息,IAT是「最终」的导入信息。加载器在加载DLL时,会遍历INT,找到每个导入函数的地址,然后填入IAT。所以,程序运行时实际调用的是IAT里的地址。
为什么会这样设计?因为这样可以让加载器在加载时一次性解析所有导入函数,程序运行时直接跳转到IAT里的地址,不用每次都查表。效率更高。
导出表的结构类似,但更简单:
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;
导出表有三个核心数组:
- AddressOfFunctions:函数地址数组,按序号索引
- AddressOfNames:函数名称数组
- AddressOfNameOrdinals:名称到序号的映射数组
查找一个导出函数的过程是:先在 AddressOfNames 里找到名称对应的索引,然后用这个索引去 AddressOfNameOrdinals 拿到序号,最后用序号去 AddressOfFunctions 拿到函数地址。嗯,绕了一点,但很实用。
NumberOfFunctions 是100,但 NumberOfNames 只有50。这意味着有50个函数只有序号没有名字。如果你按名字去查找,永远找不到它们。这种情况下,只能通过序号来调用。
3.5 资源节(.rsrc)
资源节是PE文件里最「花哨」的部分。它存放了图标、光标、位图、对话框模板、字符串表、版本信息、菜单等等。结构上,它是一棵三级树:
- 第一级:类型(RT_ICON, RT_DIALOG, RT_STRING等)
- 第二级:ID(每个类型下的具体资源ID)
- 第三级:语言(LANG_ENGLISH, LANG_CHINESE等)
每个节点都是一个 IMAGE_RESOURCE_DIRECTORY,后面跟着若干个 IMAGE_RESOURCE_DIRECTORY_ENTRY。叶子节点指向实际的资源数据。
我经常在资源节里找一些有趣的东西。比如,有些恶意软件会把配置信息、加密密钥、甚至第二阶段的shellcode藏在资源节里。你想想看,一个正常的程序不会在资源里放一个加密的二进制块,对吧?
3.6 知识体系结构图
下面这张图,是我梳理的PE文件核心结构。你可以把它当作一张「地图」,以后分析PE文件时,按图索骥就行。
这张图把PE文件从DOS头到各节、再到导入导出表的完整链路串起来了。你顺着箭头走一遍,就能理解Windows加载器是怎么一步步解析PE文件的。