3、PE文件结构解析:DOS头、NT头、节表、导入表与导出表深度剖析
说到PE文件,我估计很多刚入门的朋友第一反应就是——这不就是个exe嘛。嗯,对,也不全对。PE文件是Windows下可执行文件的标准格式,包括exe、dll、sys驱动文件,甚至一些ocx控件都是PE结构。搞逆向分析,PE结构是绕不过去的坎。我当年第一次手动解析PE头的时候,对着十六进制看了整整一下午,眼睛都快瞎了。但说实话,一旦你搞懂了这套结构,后面很多分析工作就会变得特别顺手。
3.1 DOS头:那个古老的开场白
每个PE文件的开头,都是一个DOS头。为什么叫DOS头?因为Windows还兼容DOS时代的遗产。你想想看,一个exe文件在DOS下双击,它得能运行吧?所以PE文件开头必须有一段DOS存根,告诉DOS系统“嘿,这是个DOS程序”。
DOS头的结构用C语言描述是这样的:
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; // 指向NT头的偏移,关键字段!
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
这里最关键的字段是 e_lfanew。它告诉你真正的PE头(NT头)在文件中的偏移位置。我见过一些恶意软件会故意篡改这个值,让你解析PE头时直接崩溃。嗯,遇到这种情况,你得手动计算一下,或者用工具强行修复。
3.2 NT头:PE文件的核心身份证
从 e_lfanew 指向的位置开始,就是NT头了。NT头其实是个复合结构,包含三个部分:签名、文件头、可选头。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // 签名,固定为"PE\0\0",即0x00004550
IMAGE_FILE_HEADER FileHeader; // 文件头
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 可选头(32位版本)
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
签名是"PE\0\0",四个字节。我见过有人用工具把exe改成"NE"(New Executable,16位Windows程序),结果系统直接不认。所以检查签名是判断文件是否有效PE的第一步。
3.2.1 文件头(FileHeader)
文件头里存放的是PE文件的基本元信息:
| 字段 | 含义 | 我的经验 |
|---|---|---|
| Machine | 目标CPU架构,0x014C是x86,0x8664是x64 | 分析恶意样本时,先看这个字段,判断是32位还是64位 |
| NumberOfSections | 节的数量 | 正常程序一般3-5个节,恶意软件可能多达20个 |
| SizeOfOptionalHeader | 可选头的大小 | 32位通常是0xE0,64位是0xF0 |
| Characteristics | 文件属性标志 | 比如0x0002表示可执行,0x2000表示DLL |
我曾经遇到过一个样本,它的 NumberOfSections 被改成了0,导致很多解析工具直接报错。但手动分析时,你仍然可以通过搜索节表特征来定位真正的节数据。说白了,恶意软件作者就是利用工具的死板来隐藏自己。
3.2.2 可选头(OptionalHeader)
名字叫"可选头",但实际上是必选的。它包含了程序加载运行所需的关键信息:
- Magic:0x10B表示32位,0x20B表示64位
- AddressOfEntryPoint:程序入口点的RVA(相对虚拟地址)。这是分析恶意软件时最常看的字段之一
- ImageBase:建议的加载基址。32位默认0x00400000,64位默认0x0000000140000000
- SectionAlignment 和 FileAlignment:内存对齐和文件对齐的大小
- SizeOfImage:内存中整个映像的大小
- SizeOfHeaders:所有头部的总大小
- Subsystem:子系统类型,0x2是GUI,0x3是控制台
- DataDirectory:数据目录表,包含导入表、导出表等16个目录项
3.3 节表:文件内容的组织者
NT头之后就是节表了。节表是一个数组,每个元素描述一个节(Section)。节就是PE文件中存放代码、数据、资源等内容的区域。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[8]; // 节名,如".text"、".data"、".rdata"
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 | 重定位表 | 可读+可丢弃 |
这里有个坑:节名其实是可以随便改的。恶意软件经常把代码节命名为".data"或者".rdata",让你误以为它没有可执行代码。我见过一个样本,它的所有节都叫"....",用工具看根本分不清。这时候就得靠节属性来判断——只要 Characteristics 里设置了 IMAGE_SCN_MEM_EXECUTE (0x20000000),那这个节就是可执行的,不管它叫什么名字。
3.4 导入表:程序依赖了哪些外部函数
导入表(Import Table)是PE文件中最常被分析的结构之一。它告诉操作系统:这个程序需要从哪些DLL中导入哪些函数。
导入表位于数据目录的第一个条目(索引0)。它的结构是一个 IMAGE_IMPORT_DESCRIPTOR 数组,以全0的条目结束。
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; // INT(导入名称表)的RVA
};
DWORD TimeDateStamp; // 时间戳,通常为0
DWORD ForwarderChain; // 转发链,通常为0
DWORD Name; // DLL名称字符串的RVA
DWORD FirstThunk; // IAT(导入地址表)的RVA
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;
关键字段是 OriginalFirstThunk 和 FirstThunk。它们都指向一个 IMAGE_THUNK_DATA 数组,每个元素要么是一个序号(高位为1),要么是一个指向 IMAGE_IMPORT_BY_NAME 结构的RVA。
为什么要两个表?我解释一下:OriginalFirstThunk 指向的INT(导入名称表)是原始信息,不会被修改。而 FirstThunk 指向的IAT(导入地址表)在程序加载后,会被Windows加载器替换成函数的实际地址。所以你在内存中看到的IAT里存放的是函数指针,而不是名字。
分析恶意软件时,我经常先看导入表。如果一个exe导入了 CreateRemoteThread、WriteProcessMemory、VirtualAllocEx 这些函数,那它八成是个注入器。如果一个dll导入了 RegSetValueEx、CreateService,那它可能是持久化相关的。
NtQueryInformationProcess、ZwUnmapViewOfSection,那它很可能在做反调试或者进程注入。
3.5 导出表:DLL对外暴露的接口
导出表(Export Table)是DLL文件特有的结构(exe也可以有,但很少见)。它告诉外部程序:这个DLL提供了哪些函数可以调用。
导出表位于数据目录的第0个条目(索引0)。它的结构是 IMAGE_EXPORT_DIRECTORY:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; // 未使用,通常为0
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;
导出表有三个核心数组:
- AddressOfFunctions:一个DWORD数组,每个元素是函数的RVA
- AddressOfNames:一个DWORD数组,每个元素是函数名字符串的RVA
- AddressOfNameOrdinals:一个WORD数组,将名称索引映射到函数地址索引
查找一个导出函数的流程是这样的:
- 在
AddressOfNames中二分查找函数名,得到名称索引 - 用名称索引去
AddressOfNameOrdinals中取出序号 - 用序号去
AddressOfFunctions中取出函数地址RVA
为什么要有序号?因为通过序号导出更快,不需要字符串比较。很多系统DLL的函数既有名字也有序号。我见过一些恶意DLL,它们只导出序号,不导出名字,目的就是让你逆向时找不到函数名,增加分析难度。
NumberOfFunctions 被设置得非常大,但实际上只有前几个函数是有效的。后面的地址都是垃圾数据。如果你直接遍历所有函数,就会读到无效地址,导致分析工具崩溃。所以一定要检查函数地址是否在合法的节范围内。
3.6 实战:手动解析一个PE文件
说了这么多理论,我们来点实际的。假设你有一个恶意样本,想快速定位它的导入表:
- 打开文件,读取前两个字节,确认是"MZ"
- 跳到偏移0x3C,读取4字节,得到e_lfanew的值
- 跳到e_lfanew位置,读取4字节,确认是"PE\0\0"
- 跳过FileHeader(20字节),进入OptionalHeader
- OptionalHeader中,数据目录从偏移0x60(32位)或0x70(64位)开始
- 第一个数据目录项就是导入表,它包含两个DWORD:VirtualAddress和Size
- 根据VirtualAddress找到导入描述符数组,开始解析每个DLL的导入函数
这个过程听起来复杂,但用Python写个脚本也就几十行。我习惯用 pefile 库,几行代码就能搞定所有解析工作。但手动走一遍流程,能帮你深刻理解PE结构的本质。
- 010 Editor + PE模板:手动查看和编辑PE结构
- CFF Explorer:图形化查看PE所有结构
- PE-bear:轻量级PE分析工具,我最近很喜欢用
- pefile (Python库):写自动化分析脚本的首选
好了,PE文件的核心结构就讲到这里。说实话,PE结构的内容远不止这些,还有重定位表、资源表、TLS表、延迟导入表等等。但掌握了DOS头、NT头、节表、导入导出表,你已经能应对90%以上的逆向分析场景了。剩下的那些,遇到具体问题时再去查资料也不迟。
下一章我们会讲如何用动态分析技术来追踪恶意软件的行为,到时候会用到今天讲的这些知识。嗯,先消化消化吧。