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头时直接崩溃。嗯,遇到这种情况,你得手动计算一下,或者用工具强行修复。

小技巧: 用十六进制编辑器打开一个exe,开头两个字节是"4D 5A"(MZ),然后直接跳到偏移0x3C的位置,那里存放的就是e_lfanew的值。我习惯用010 Editor,直接点一下就能看到解析结果。

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个目录项
重点: AddressOfEntryPoint 是恶意软件常动手脚的地方。有些病毒会修改入口点指向自己的代码,实现先于主程序执行。我分析过一个勒索软件,它的入口点直接指向了加密函数,根本没给主程序任何机会。

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),那这个节就是可执行的,不管它叫什么名字。

注意: 文件对齐和内存对齐不同。文件里节与节之间可能有空隙(填充的0),但加载到内存后,节是按SectionAlignment对齐的。所以你在文件里看到的偏移和内存中的RVA不是简单的线性关系。我刚开始学的时候,经常搞混这个,导致解析导入表时总是算错地址。

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,那它可能是持久化相关的。

实战技巧: 用CFF Explorer或PE-bear查看导入表非常方便。我习惯先看导入了哪些DLL,再看每个DLL导入了哪些函数。如果发现一个程序导入了大量不常用的API,比如 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数组,将名称索引映射到函数地址索引

查找一个导出函数的流程是这样的:

  1. 在 AddressOfNames 中二分查找函数名,得到名称索引
  2. 用名称索引去 AddressOfNameOrdinals 中取出序号
  3. 用序号去 AddressOfFunctions 中取出函数地址RVA

为什么要有序号?因为通过序号导出更快,不需要字符串比较。很多系统DLL的函数既有名字也有序号。我见过一些恶意DLL,它们只导出序号,不导出名字,目的就是让你逆向时找不到函数名,增加分析难度。

避坑指南: 我曾经分析过一个Rootkit,它的导出表里 NumberOfFunctions 被设置得非常大,但实际上只有前几个函数是有效的。后面的地址都是垃圾数据。如果你直接遍历所有函数,就会读到无效地址,导致分析工具崩溃。所以一定要检查函数地址是否在合法的节范围内。

3.6 实战:手动解析一个PE文件

说了这么多理论,我们来点实际的。假设你有一个恶意样本,想快速定位它的导入表:

  1. 打开文件,读取前两个字节,确认是"MZ"
  2. 跳到偏移0x3C,读取4字节,得到e_lfanew的值
  3. 跳到e_lfanew位置,读取4字节,确认是"PE\0\0"
  4. 跳过FileHeader(20字节),进入OptionalHeader
  5. OptionalHeader中,数据目录从偏移0x60(32位)或0x70(64位)开始
  6. 第一个数据目录项就是导入表,它包含两个DWORD:VirtualAddress和Size
  7. 根据VirtualAddress找到导入描述符数组,开始解析每个DLL的导入函数

这个过程听起来复杂,但用Python写个脚本也就几十行。我习惯用 pefile 库,几行代码就能搞定所有解析工作。但手动走一遍流程,能帮你深刻理解PE结构的本质。

推荐工具:
  • 010 Editor + PE模板:手动查看和编辑PE结构
  • CFF Explorer:图形化查看PE所有结构
  • PE-bear:轻量级PE分析工具,我最近很喜欢用
  • pefile (Python库):写自动化分析脚本的首选

好了,PE文件的核心结构就讲到这里。说实话,PE结构的内容远不止这些,还有重定位表、资源表、TLS表、延迟导入表等等。但掌握了DOS头、NT头、节表、导入导出表,你已经能应对90%以上的逆向分析场景了。剩下的那些,遇到具体问题时再去查资料也不迟。

下一章我们会讲如何用动态分析技术来追踪恶意软件的行为,到时候会用到今天讲的这些知识。嗯,先消化消化吧。