0216、Windows 逆向提升:PE 前置与头结构
返回:Windows 逆向提升篇 · Windows 逆向总目录 · 网络安全学习主页
提升篇的第一块先不急着写复杂项目,而是把 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.Magic、FileHeader.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 | 观察加载后的内存、入口点和代码逻辑 |
复盘方式
拿一个自己编译的最小控制台程序做样本,然后同时打开:
- PE 查看器,看结构化字段。
- 十六进制工具,看真实文件偏移。
- 调试器,看加载到内存后的模块基址、入口点和节区属性。
三个视角对上,PE 结构就不再只是字段表。
01.4 十六进制数据分析工具开发
这一节可以理解为:先做一个简化版的二进制查看器,为后面的 PE 解析工具打底。
最小功能
| 功能 | 作用 |
|---|---|
| 打开文件 | 把目标 PE 读入内存缓冲区 |
| 显示偏移 | 每一行显示文件偏移,便于定位字段 |
| 十六进制显示 | 按字节展示原始二进制数据 |
| ASCII 显示 | 右侧显示可见字符,辅助识别字符串 |
| 范围定位 | 能跳转到某个偏移,观察结构体所在区域 |
数据展示样式
1 | 00000000 4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00 MZ.............. |
第一行的 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 | VA = ImageBase + RVA |
FOA 转换必须先找到 RVA 落在哪个节区里。
01.6 PE 文件二进制对比工具(一)
二进制对比工具的第一步,是把两个文件按字节读进来,然后逐字节比较。
1 | old_file[i] != new_file[i] -> 记录偏移 i、旧值、新值 |
用途
| 场景 | 说明 |
|---|---|
| 编译前后对比 | 观察改一行代码后 PE 哪些区域变化 |
| 字段修改验证 | 手动改字段后确认改动位置 |
| 节区变化观察 | 新增节、扩大节区时观察头部和节表变化 |
| 补丁定位 | 找到关键字节变化范围 |
这一节重点不是做一个复杂工具,而是训练“偏移意识”:每一次变化都应该能落到文件偏移上。
01.7 PE 文件二进制对比工具(二)
只比较“不同字节”还不够,最好把差异组织成连续区间。
| 设计 | 说明 |
|---|---|
| 差异起点 | 第一处不同字节 |
| 差异终点 | 连续不同区域的结束位置 |
| 差异长度 | 本段变化的字节数 |
| 新旧内容 | 展示变化前后的十六进制片段 |
示例
1 | offset: 00000120 |
如果变化内容能对应到 .text、.rdata、.idata 这类节名,就说明二进制对比已经和 PE 结构联系起来了。
01.8 PE 文件二进制对比工具(三)
第三步可以把差异和 PE 字段解释结合起来。
| 改进方向 | 例子 |
|---|---|
| 标注字段 | 变化发生在 IMAGE_FILE_HEADER.NumberOfSections |
| 标注节区 | 变化发生在 .text 或 .rdata 内 |
| 标注地址 | 同时显示 FOA、RVA、VA |
| 导出报告 | 把差异写成文本或表格,便于复盘 |
对后面的 PE 修改器来说,这个工具很有用。因为每次改节表、移动导入表、改入口点,都需要知道到底动了哪些字节。
01.9 PE 文件头和节表结构
PE 文件的主体骨架可以先按这个顺序记:
1 | DOS Header |
关键结构
| 结构 | 作用 |
|---|---|
IMAGE_DOS_HEADER |
文件开头,包含 MZ 和 e_lfanew |
IMAGE_NT_HEADERS |
PE 主头部,由签名、文件头和可选头组成 |
IMAGE_FILE_HEADER |
描述机器类型、节数量、时间戳、可选头大小和文件特征 |
IMAGE_OPTIONAL_HEADER |
描述入口点、映像基址、对齐、映像大小和数据目录 |
IMAGE_SECTION_HEADER |
描述每个节区的名称、内存位置、文件位置和属性 |
最小定位路径
1 | 文件基址 |
节表位置通常可以这样理解:
1 | SectionTable = NTHeader + 4 + sizeof(IMAGE_FILE_HEADER) + SizeOfOptionalHeader |
其中 4 是 PE\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 | e_magic == MZ |
02.2 PE 程序启动校验与非核心字段覆盖探测
PE 启动校验不能只看文件扩展名,应该从结构出发。
基础校验
| 校验项 | 说明 |
|---|---|
| 文件大小 | 至少能容纳 DOS 头 |
e_magic |
必须是 MZ |
e_lfanew |
必须在文件范围内 |
| NT 签名 | e_lfanew 位置应为 PE\0\0 |
| OptionalHeader 大小 | 需要能定位到节表 |
| 节数量 | 节表不能越过文件边界 |
非核心字段
DOS 头里很多字段并不会直接影响现代 PE 的加载。复盘时可以把字段分成两类:
| 类型 | 字段例子 | 说明 |
|---|---|---|
| 核心字段 | e_magic、e_lfanew |
影响 PE 识别和 NT 头定位 |
| 兼容字段 | DOS 时代遗留字段 | 多数用于历史兼容或 Stub 逻辑 |
真正做解析工具时,不要因为某个非核心字段“不常用”就随意忽略边界检查。损坏文件、混淆样本和异常 PE 很容易在这里制造解析错误。
02.3 IMAGE_NT_HEADERS 定位与成员组成
NT 头由 e_lfanew 定位。
1 | auto dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(file_base); |
逻辑上包含三块:
| 成员 | 说明 |
|---|---|
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 | section_table = |
如果 NumberOfSections 很大,或者节表计算后超过文件大小,就应该认为文件异常,而不是继续强行解析。
最小解析思路
下面是一个偏伪代码的 PE 头部解析框架,重点是“每一步都做边界检查”。
1 | bool parse_pe_headers(const std::vector<std::uint8_t>& file) |
这段代码不追求完整,只强调解析习惯:先校验,再转换,再读取。
常见错误整理
| 错误 | 后果 |
|---|---|
| 只看扩展名判断 PE | .exe 也可能是损坏文件或伪装文件 |
不检查 e_lfanew 边界 |
容易越界读取或崩溃 |
| 写死 OptionalHeader 大小 | 32 位、64 位或异常文件下可能定位错误 |
| 混淆 RVA 和 FOA | 文件中找不到内存地址对应的数据 |
| 忽略对齐 | 节区映射后文件状态和内存状态对不上 |
| 只看工具不写代码 | 字段看起来懂了,但自己无法复现定位过程 |
学习检查清单
| 检查项 | 状态 |
|---|---|
| 能说清 PE 文件状态和内存状态的区别 | 待复盘 |
能解释 VA、RVA、FOA 三个概念 |
待复盘 |
能在十六进制工具中找到 MZ 和 PE\0\0 |
待复盘 |
能说明 e_lfanew 为什么重要 |
待复盘 |
| 能画出 DOS Header、NT Headers、Section Table 的顺序 | 待复盘 |
能解释 IMAGE_FILE_HEADER.NumberOfSections 的作用 |
待复盘 |
能用 SizeOfOptionalHeader 定位节表 |
待复盘 |
| 能写出最小 PE 头部校验伪代码 | 待复盘 |
这一块复盘完,不要求马上记住所有字段。先做到:能从文件开头定位到 NT 头,能从 NT 头定位到节表,能意识到每次解析都要做边界检查。PE 后面的复杂内容,都会建立在这个骨架上。






