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)。我早期调试一个加壳样本时,就因为没注意这个对齐差异,算偏移算错了整整一下午……

核心要点:PE文件是Windows加载器解析可执行文件的唯一标准。所有逆向分析,第一步就是读懂PE结构。

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不同)

文件头里我最常看的是 NumberOfSectionsSizeOfOptionalHeader。前者告诉你这个PE文件有多少个节,后者告诉你可选头有多大。为什么重要?因为节表紧跟在可选头后面,如果你不知道可选头大小,你就不知道节表从哪开始。

个人习惯:我每次拿到一个陌生样本,第一件事就是读e_lfanew,然后跳到NT头,确认Signature是不是"PE\0\0"。如果不是,那这个文件八成被处理过了。

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,属性设成了可读可写可执行,加载器照样正常加载。所以,别迷信节名。

避坑指南:解析节表时,一定要检查 SizeOfRawDataVirtualSize 是否合理。有些加壳工具会把 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 拿到函数地址。嗯,绕了一点,但很实用。

避坑指南:我曾经遇到过一个DLL,它的 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藏在资源节里。你想想看,一个正常的程序不会在资源里放一个加密的二进制块,对吧?

实战技巧:用工具(比如Resource Hacker或CFF Explorer)查看资源节,可以快速发现异常。如果看到一个类型为RT_RCDATA的资源,大小却有几MB,那八成有问题。

3.6 知识体系结构图

下面这张图,是我梳理的PE文件核心结构。你可以把它当作一张「地图」,以后分析PE文件时,按图索骥就行。

PE文件核心结构图 DOS MZ Header DOS Stub PE Signature NT Header (FileHeader + OptionalHeader) Section Table (节表) .text (代码) .data / .rdata (数据) .rsrc (资源) 导入表(IAT) / 导出表(EDT) e_lfanew 字段指向 PE Signature 起始位置,是解析的入口点 节表描述每个节在磁盘和内存中的位置、大小及属性

这张图把PE文件从DOS头到各节、再到导入导出表的完整链路串起来了。你顺着箭头走一遍,就能理解Windows加载器是怎么一步步解析PE文件的。

总结一下:PE结构并不复杂,关键就几个点——DOS头的e_lfanew、NT头的Signature、节表的VirtualAddress和PointerToRawData、导入表的INT和IAT、导出表的三个数组。把这些搞懂了,大部分PE分析工作你都能应付。
个人建议:初学者可以拿一个简单的EXE文件,用十六进制编辑器打开,对照着结构体定义,手动解析一遍。我当年就是这么干的,虽然慢,但效果极好。解析完一个文件,你对PE的理解会比看十篇文章都深。