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)

导入表的结构有点绕,但核心思路很简单:

  1. 每个被导入的DLL对应一个IMAGE_IMPORT_DESCRIPTOR结构。
  2. 这个结构里有一个OriginalFirstThunk(INT)和一个FirstThunk(IAT)。
  3. 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文件的核心结构串起来了。你对照着看,能更直观地理解各部分的关系。

PE文件结构总览 PE文件结构总览 DOS头 (MZ) NT头 (PE\0\0) 节表 (Section Table) NT头内部 Signature (4B) FileHeader (20B) OptionalHeader (可变) └─ DataDirectory[] (导入/导出/资源/重定位) 节表项 (40B/个) Name (8B) VirtualSize / VirtualAddress SizeOfRawData / PointerToRawData Characteristics (可读/可写/可执行) 节区 (Sections) .text 代码节 (可执行) .data 数据节 (可读写) .rsrc 资源节 .reloc 重定位表 .idata 导入表 说明:节区在文件中的顺序由节表决定,名称只是约定俗成,恶意软件可以随意命名 解析时务必以节表为准,不要依赖节名称

3.6 本章小结

PE文件结构是二进制逆向的基石。DOS头和NT头是入口,节表是索引,导入导出表是通信接口,资源节和重定位表是辅助数据。你把这些搞清楚了,后面分析加壳、HOOK、漏洞利用才能得心应手。

最后说一句:我建议你找个真实的PE文件,用010 Editor或者CFF Explorer对照着看一遍。光看书不实践,永远记不住。嗯,动手吧。