提升篇的第一块先不急着写复杂项目,而是把 PE 文件当成一个可以拆开的二进制对象来看:文件里每个字段在哪里,加载到内存后地址怎么变化,工具显示的偏移和代码里拿到的指针怎么对应。

这一部分的目标很明确:以后看到一个 .exe.dll,至少能先找到 DOS 头、NT 头、文件头、可选头和节表,知道哪些字段是加载器真正关心的,哪些字段更偏兼容、标识或辅助信息。

01.0 逆向工程提升篇前置知识

进入 PE 结构之前,需要先把几类基础知识接起来。

前置知识 需要能做到什么
C/C++ 结构体 能理解结构体字段顺序、大小、对齐和指针转换
指针和数组 能用“基址 + 偏移”定位二进制文件里的结构
十六进制 能习惯用 0x 表示偏移、地址、标志位和字段值
小端序 能看懂文件中低位字节在前的整数存储方式
汇编与调试 能用 VS、DBG 或 IDA 观察模块基址、入口点和内存窗口
文件读写 能把 PE 文件读入内存缓冲区,再按结构体解析

PE 解析最核心的动作其实只有一个:

1
文件缓冲区基址 + 字段偏移 = 对应结构体或字段位置

后面所有 DOS 头、NT 头、节表、导入表、导出表,本质上都是这个动作的延伸。

01.1 逆向工程提升篇学习路线(一)

提升篇可以先分成三段。

阶段 内容 目标
PE 基础 DOS 头、NT 头、OptionalHeader、节表、地址换算 看懂 PE 文件的骨架
数据目录 导出表、导入表、资源、异常、证书、调试信息、TLS 看懂 PE 文件里的功能索引
项目实践 解析工具、PE 修改器、加载风险评估、扫描器项目 把字段理解转成工具能力

第一阶段最重要。后面的导入、导出、重定位、资源和异常,都挂在 OptionalHeader 的 DataDirectory 或节区数据里。如果前面的偏移和地址换算不稳,后面很容易一层错、层层错。

01.2 逆向工程提升篇学习路线(二)

学习 PE 不建议只背字段名,最好按“问题”来复盘。

问题 对应知识
这个文件是不是 PE? DOS 头 MZ、NT 头 PE\0\0
NT 头在哪里? IMAGE_DOS_HEADER.e_lfanew
这是 32 位还是 64 位? OptionalHeader.MagicFileHeader.Machine
程序入口在哪里? AddressOfEntryPoint + ImageBase
文件偏移和内存地址怎么换? 节表、RVA、FOA、对齐
程序依赖哪些 DLL/API? Import Directory
DLL 导出了哪些函数? Export Directory
换基址后哪些地址要修? Relocation Directory

这样整理的好处是:以后做工具或看样本时,不会只记得“有个字段”,而是知道这个字段能解决什么问题。

01.3 PE 文件结构与工具解析使用

PE 是 Windows 下常见可执行文件、动态链接库和驱动程序使用的文件格式。常见文件包括:

类型 说明
.exe 可执行程序
.dll 动态链接库
.sys 驱动文件
.ocx 控件模块

常用工具可以分成三类。

工具类型 示例 用途
PE 查看器 CFF Explorer、PE-bear、LordPE 快速查看 DOS/NT/节表/数据目录
十六进制编辑器 010 Editor、HxD 直接观察文件偏移和原始字节
反汇编/调试器 IDA、x64dbg、Visual Studio 观察加载后的内存、入口点和代码逻辑

复盘方式

拿一个自己编译的最小控制台程序做样本,然后同时打开:

  1. PE 查看器,看结构化字段。
  2. 十六进制工具,看真实文件偏移。
  3. 调试器,看加载到内存后的模块基址、入口点和节区属性。

三个视角对上,PE 结构就不再只是字段表。

01.4 十六进制数据分析工具开发

这一节可以理解为:先做一个简化版的二进制查看器,为后面的 PE 解析工具打底。

最小功能

功能 作用
打开文件 把目标 PE 读入内存缓冲区
显示偏移 每一行显示文件偏移,便于定位字段
十六进制显示 按字节展示原始二进制数据
ASCII 显示 右侧显示可见字符,辅助识别字符串
范围定位 能跳转到某个偏移,观察结构体所在区域

数据展示样式

1
2
00000000  4D 5A 90 00 03 00 00 00  04 00 00 00 FF FF 00 00  MZ..............
00000010 B8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00 ........@.......

第一行的 4D 5A 就是 MZ,这是 DOS 头的签名。

01.5 PE 文件结构:文件状态与内存状态

PE 文件有两个常见视角。

状态 说明
文件状态 PE 文件还在磁盘上,按文件偏移 FOA 查找数据
内存状态 PE 被加载到进程地址空间后,按虚拟地址 VA/RVA 查找数据

这两种状态不完全一样。原因是 PE 加载到内存时,会按照节区对齐重新映射。

三个地址概念

名称 含义
VA Virtual Address,进程中的虚拟地址
RVA Relative Virtual Address,相对模块基址的偏移
FOA File Offset Address,文件中的偏移

常见公式:

1
2
3
VA  = ImageBase + RVA
RVA = VA - ImageBase
FOA = RVA - Section.VirtualAddress + Section.PointerToRawData

FOA 转换必须先找到 RVA 落在哪个节区里。

01.6 PE 文件二进制对比工具(一)

二进制对比工具的第一步,是把两个文件按字节读进来,然后逐字节比较。

1
old_file[i] != new_file[i] -> 记录偏移 i、旧值、新值

用途

场景 说明
编译前后对比 观察改一行代码后 PE 哪些区域变化
字段修改验证 手动改字段后确认改动位置
节区变化观察 新增节、扩大节区时观察头部和节表变化
补丁定位 找到关键字节变化范围

这一节重点不是做一个复杂工具,而是训练“偏移意识”:每一次变化都应该能落到文件偏移上。

01.7 PE 文件二进制对比工具(二)

只比较“不同字节”还不够,最好把差异组织成连续区间。

设计 说明
差异起点 第一处不同字节
差异终点 连续不同区域的结束位置
差异长度 本段变化的字节数
新旧内容 展示变化前后的十六进制片段

示例

1
2
3
offset: 00000120
old : 00 00 00 00 00 00 00 00
new : 2E 74 65 78 74 00 00 00

如果变化内容能对应到 .text.rdata.idata 这类节名,就说明二进制对比已经和 PE 结构联系起来了。

01.8 PE 文件二进制对比工具(三)

第三步可以把差异和 PE 字段解释结合起来。

改进方向 例子
标注字段 变化发生在 IMAGE_FILE_HEADER.NumberOfSections
标注节区 变化发生在 .text.rdata
标注地址 同时显示 FOA、RVA、VA
导出报告 把差异写成文本或表格,便于复盘

对后面的 PE 修改器来说,这个工具很有用。因为每次改节表、移动导入表、改入口点,都需要知道到底动了哪些字节。

01.9 PE 文件头和节表结构

PE 文件的主体骨架可以先按这个顺序记:

1
2
3
4
5
6
7
8
DOS Header
DOS Stub
NT Headers
Signature
IMAGE_FILE_HEADER
IMAGE_OPTIONAL_HEADER
Section Table
Section Data

关键结构

结构 作用
IMAGE_DOS_HEADER 文件开头,包含 MZe_lfanew
IMAGE_NT_HEADERS PE 主头部,由签名、文件头和可选头组成
IMAGE_FILE_HEADER 描述机器类型、节数量、时间戳、可选头大小和文件特征
IMAGE_OPTIONAL_HEADER 描述入口点、映像基址、对齐、映像大小和数据目录
IMAGE_SECTION_HEADER 描述每个节区的名称、内存位置、文件位置和属性

最小定位路径

1
2
3
4
5
6
7
文件基址
-> IMAGE_DOS_HEADER
-> e_lfanew
-> IMAGE_NT_HEADERS
-> FileHeader.NumberOfSections
-> OptionalHeader.SizeOfOptionalHeader
-> Section Table

节表位置通常可以这样理解:

1
SectionTable = NTHeader + 4 + sizeof(IMAGE_FILE_HEADER) + SizeOfOptionalHeader

其中 4PE\0\0 签名占用的字节数。

02.0 PE 文件加载修复过程

PE 从磁盘文件变成进程中的模块,大致会经历这些动作:

步骤 含义
读取头部 校验 DOS 头、NT 头和节表
分配内存 SizeOfImage 申请模块映像空间
映射头部 把 Header 区域复制到内存
映射节区 按节表把每个节复制到对应 RVA
修复导入 根据导入表加载 DLL 并填充 IAT
修复重定位 如果实际基址不是首选基址,则修正需要换基址的地址
设置保护 根据节属性设置读、写、执行权限
跳转入口 ImageBase + AddressOfEntryPoint 开始执行

这里先建立整体图,后面导入表、重定位、节区保护会拆开整理。

02.1 DOS_HEADER 成员解析、内存布局及 DOS Stub

DOS 头位于 PE 文件最开头,结构名是 IMAGE_DOS_HEADER

最重要的字段

字段 含义
e_magic DOS 签名,正常 PE 文件开头是 MZ,十六进制为 0x5A4D
e_lfanew NT 头在文件中的偏移

虽然 DOS 头里有很多历史兼容字段,但初学阶段先抓住这两个就够了。

DOS Stub

DOS Stub 是 DOS 头后面的一段兼容代码,常见字符串是:

1
This program cannot be run in DOS mode.

现代 Windows 加载 PE 时,核心路径不依赖这段提示文本。真正关键的是:

1
2
e_magic == MZ
e_lfanew 能定位到 PE 签名

02.2 PE 程序启动校验与非核心字段覆盖探测

PE 启动校验不能只看文件扩展名,应该从结构出发。

基础校验

校验项 说明
文件大小 至少能容纳 DOS 头
e_magic 必须是 MZ
e_lfanew 必须在文件范围内
NT 签名 e_lfanew 位置应为 PE\0\0
OptionalHeader 大小 需要能定位到节表
节数量 节表不能越过文件边界

非核心字段

DOS 头里很多字段并不会直接影响现代 PE 的加载。复盘时可以把字段分成两类:

类型 字段例子 说明
核心字段 e_magice_lfanew 影响 PE 识别和 NT 头定位
兼容字段 DOS 时代遗留字段 多数用于历史兼容或 Stub 逻辑

真正做解析工具时,不要因为某个非核心字段“不常用”就随意忽略边界检查。损坏文件、混淆样本和异常 PE 很容易在这里制造解析错误。

02.3 IMAGE_NT_HEADERS 定位与成员组成

NT 头由 e_lfanew 定位。

1
2
auto dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(file_base);
auto nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(file_base + dos->e_lfanew);

逻辑上包含三块:

成员 说明
Signature PE 签名,正常为 PE\0\0
FileHeader 文件头,描述机器类型、节数量和文件特征
OptionalHeader 可选头,描述入口点、基址、对齐、大小、数据目录等

OptionalHeader 名字叫“可选头”,但对可执行 PE 来说非常关键。入口点、ImageBase、SectionAlignment、FileAlignment 和 DataDirectory 都在这里。

32 位与 64 位

字段 PE32 PE32+
OptionalHeader.Magic 0x10B 0x20B
ImageBase 宽度 32 位 64 位
栈/堆保留字段 32 位 64 位

解析工具最好先读 Magic,再决定按 32 位还是 64 位结构解释。

02.4 IMAGE_FILE_HEADER 成员解析与字段

IMAGE_FILE_HEADER 是 NT 头中的文件头,大小相对固定,字段不多。

字段 含义
Machine 目标机器类型,例如 x86、x64
NumberOfSections 节区数量
TimeDateStamp 时间戳
PointerToSymbolTable COFF 符号表偏移,普通 PE 中常见为 0
NumberOfSymbols 符号数量,普通 PE 中常见为 0
SizeOfOptionalHeader OptionalHeader 的大小,用于定位节表
Characteristics 文件特征标志,例如可执行、DLL、32 位等

常见机器类型

含义
0x014C Intel 386,也就是常见 x86
0x8664 AMD64,也就是常见 x64

节表定位

节表位置依赖 SizeOfOptionalHeader,不要写死 OptionalHeader 的大小。

1
2
3
4
5
6
section_table =
file_base
+ dos->e_lfanew
+ sizeof(Signature)
+ sizeof(IMAGE_FILE_HEADER)
+ file_header->SizeOfOptionalHeader

如果 NumberOfSections 很大,或者节表计算后超过文件大小,就应该认为文件异常,而不是继续强行解析。

最小解析思路

下面是一个偏伪代码的 PE 头部解析框架,重点是“每一步都做边界检查”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
bool parse_pe_headers(const std::vector<std::uint8_t>& file)
{
if (file.size() < sizeof(IMAGE_DOS_HEADER)) {
return false;
}

const auto* dos =
reinterpret_cast<const IMAGE_DOS_HEADER*>(file.data());

if (dos->e_magic != IMAGE_DOS_SIGNATURE) {
return false;
}

if (dos->e_lfanew <= 0 ||
static_cast<std::size_t>(dos->e_lfanew) > file.size() - sizeof(IMAGE_NT_HEADERS)) {
return false;
}

const auto* nt =
reinterpret_cast<const IMAGE_NT_HEADERS*>(file.data() + dos->e_lfanew);

if (nt->Signature != IMAGE_NT_SIGNATURE) {
return false;
}

const auto& file_header = nt->FileHeader;
const std::size_t section_table_offset =
static_cast<std::size_t>(dos->e_lfanew)
+ sizeof(nt->Signature)
+ sizeof(IMAGE_FILE_HEADER)
+ file_header.SizeOfOptionalHeader;

const std::size_t section_table_size =
static_cast<std::size_t>(file_header.NumberOfSections)
* sizeof(IMAGE_SECTION_HEADER);

if (section_table_offset > file.size() ||
section_table_size > file.size() - section_table_offset) {
return false;
}

return true;
}

这段代码不追求完整,只强调解析习惯:先校验,再转换,再读取。

常见错误整理

错误 后果
只看扩展名判断 PE .exe 也可能是损坏文件或伪装文件
不检查 e_lfanew 边界 容易越界读取或崩溃
写死 OptionalHeader 大小 32 位、64 位或异常文件下可能定位错误
混淆 RVA 和 FOA 文件中找不到内存地址对应的数据
忽略对齐 节区映射后文件状态和内存状态对不上
只看工具不写代码 字段看起来懂了,但自己无法复现定位过程

学习检查清单

检查项 状态
能说清 PE 文件状态和内存状态的区别 待复盘
能解释 VARVAFOA 三个概念 待复盘
能在十六进制工具中找到 MZPE\0\0 待复盘
能说明 e_lfanew 为什么重要 待复盘
能画出 DOS Header、NT Headers、Section Table 的顺序 待复盘
能解释 IMAGE_FILE_HEADER.NumberOfSections 的作用 待复盘
能用 SizeOfOptionalHeader 定位节表 待复盘
能写出最小 PE 头部校验伪代码 待复盘

这一块复盘完,不要求马上记住所有字段。先做到:能从文件开头定位到 NT 头,能从 NT 头定位到节表,能意识到每次解析都要做边界检查。PE 后面的复杂内容,都会建立在这个骨架上。