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文件,布局基本一致,但有些字段的值会不同。比如节在磁盘上是对齐到文件对齐粒度,在内存中是对齐到页面粒度。我当年做脱壳工具时就因为这个对齐问题折腾了好几天。

PE文件结构总览 DOS MZ Header (64字节) DOS Stub (可变) PE Signature (4字节) IMAGE_FILE_HEADER (20字节) IMAGE_OPTIONAL_HEADER Section Table (节表) .text (代码节) .data (数据节) .rdata (只读数据) .rsrc (资源节) .reloc (重定位) 关键偏移 • e_magic: 0x5A4D ("MZ") • e_lfanew: 指向PE签名 • PE签名: 0x00004550 • Machine: CPU架构 • NumberOfSections: 节数量 • SizeOfOptionalHeader • Characteristics: 文件属性 • ImageBase: 首选加载基址 • SizeOfImage: 内存映像大小 • EntryPoint: 入口点RVA

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头藏到文件中间去,用来躲避静态扫描。嗯,遇到这种情况,你得手动计算偏移。

小技巧: 用十六进制编辑器打开一个exe文件,看前两个字节是不是"4D 5A"(MZ)。然后跳到偏移0x3C处,读取4个字节,那就是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;

我个人习惯重点关注两个字段:NumberOfSectionsCharacteristics。前者告诉你文件有多少个节,后者告诉你文件是exe还是dll,是不是系统文件等等。

避坑指南: 我曾经遇到过一个加壳程序,它把NumberOfSections改成了0,导致很多解析器直接崩溃。实际上真正的节信息被加密存储在其他地方。遇到这种情况,你得通过扫描节表特征来恢复。

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;

这里最核心的就是VirtualAddressPointerToRawData。它们建立了文件偏移和内存RVA之间的映射关系。说白了,你要在内存中访问某个数据,得先通过节表找到它在文件中的位置。

注意: 节名只是给人看的,Windows加载器根本不关心节名。你可以把代码节命名为".data",把数据节命名为".text",系统照样加载。我见过一些病毒就是这么干的,用来迷惑分析人员。

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的依据。

实战技巧: 用LordPE或CFF Explorer查看导入表时,如果发现某个DLL的IAT全是0,说明这个DLL可能被动态加载了。我曾经分析过一个恶意软件,它就是用这种方式隐藏自己的导入函数。

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;

这里有三个表:地址表、名称表、序号表。查找一个导出函数的过程是:

  1. 在名称表中二分查找函数名,得到索引
  2. 用这个索引去序号表,得到序号
  3. 用序号减去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,不妨用十六进制编辑器看看,说不定就能发现问题的根源。