PE文件结构解析:从MZ头、节表到RVA与文件偏移换算
1. 从一个双击动作说起系统到底认的是什么很多人第一次听到“PE文件”这个词是在逆向、病毒分析或者做安装包的场景里感觉离自己很远。其实你每天开着的浏览器、聊天工具、游戏客户端几乎全是 PE 文件。所谓 PE全称 Portable Executable可移植可执行文件格式是 Windows 平台上可执行程序、动态链接库、驱动、控件等一整套二进制文件的统一容器规范。你把一个 exe 拖到十六进制编辑器里头两个字节是4D 5A也就是字符MZ这两个字母就是 PE 家族的门牌号。我见过太多人卡在第一步知道 PE 是个格式但不知道它到底“格式”了什么。简单说它规定了操作系统加载器要按什么顺序读这个文件、代码放哪、数据放哪、依赖哪些外部库、入口点在哪、内存里该展开多大。没有这套约定加载器面对一堆二进制字节就无从下手。你可以把它理解成一份写给操作系统的“施工图纸”哪里是承重墙代码段哪里是仓库数据段哪里是外部采购清单导入表图纸上标得清清楚楚加载器照着搬砖就行。这篇文章适合三类人看。第一类是刚接触底层、想搞明白 exe 内部长什么样的开发者第二类是做安全分析、需要手工解析样本的从业者第三类是做安装打包、外挂注入、兼容性排查被迫要和 PE 结构打交道的工程师。我不会只给你念结构体字段而是把“为什么要这么设计”“实际解析时会错在哪”一起讲清楚最后附上可以直接跑的解析脚本你拿着就能对照自己手边的文件验证。先给一个反直觉的结论PE 文件在磁盘上的布局和在内存里的布局通常不是一回事。磁盘上为了节省空间按 512 字节对齐内存里为了分页管理按 4096 字节对齐同一个节区在两边的偏移完全不同。绝大多数解析错误、地址算错、读不到数据的问题根子都在这里。所以后面我会花不少篇幅专门讲对齐和地址换算这是整个 PE 解析的地基。2. 拆解顺序从 MZ 头一路读到节表2.1 DOS 头那段没人用却必须存在的 64 字节文件开头这 64 个字节叫 DOS 头结构体名是IMAGE_DOS_HEADER。它诞生于 DOS 时代现在真正有用的字段只剩两个开头的e_magic固定0x5A4D和偏移0x3C处的e_lfanew。前者是校验后者是一个 4 字节的“指路牌”告诉解析器真正的 PE 头在文件哪个位置开始。中间那几十个字节基本是历史遗留包括各种 DOS 时代的寄存器初值、页数、校验和等。更后面还跟着一段 DOS Stub 程序短的几十字节长的上百字节内容是“This program cannot be run in DOS mode”之类的提示。这段东西在现代系统上永远不会被执行但它的存在有实际意义如果你在纯 DOS 环境下运行这个 exe系统读到 MZ 头会先跑 Stub打印一句提示再退出而不是直接崩溃成一堆乱码。实操提醒解析时千万不要假设 PE 头就在偏移 0x40 或者某个固定位置。一定要读e_lfanew再跳转。我见过有人图省事直接硬编码偏移结果遇到某些加壳或特殊链接器产出的文件头部被塞了额外数据e_lfanew指向 0x100 甚至更后面硬编码的代码直接读出一堆垃圾。还有一点值得注意e_lfanew是 4 字节有符号还是无符号不同资料写法不一实际按无符号 32 位读就行。如果这个值大得离谱比如超过文件大小那基本可以判定文件被破坏或者根本就是个伪装成 exe 的其他文件这时候应该果断报错而不是继续往下读。2.2 NT 头文件头管全局可选头管加载顺着e_lfanew跳过去先是 4 字节签名50 45 00 00也就是PE\0\0。这 4 个字节是判断“是不是 PE 文件”最硬的依据比 MZ 可靠得多因为任何文件都可以在前两个字节写 MZ。签名之后紧跟着 20 字节的IMAGE_FILE_HEADER我习惯叫它文件头。文件头里最该关注的三个字段Machine表示目标架构0x14C 是 32 位 x860x8664 是 64 位 x640xAA64 是 ARM64NumberOfSections是节区数量一般 5 到 10 个超过 20 个就要警惕可能是加壳或者手工构造的异常样本SizeOfOptionalHeader是紧接着的可选头长度32 位程序通常是 22464 位是 240这个值必须读不能写死因为不同链接器可能有差异。文件头之后就是可选头IMAGE_OPTIONAL_HEADER。名字叫“可选”其实对可执行文件来说它是必需的叫“可选”只是历史命名习惯。这里面信息密度极高AddressOfEntryPoint是程序入口的 RVA相对虚拟地址ImageBase是建议的加载基址SectionAlignment和FileAlignment是两个对齐粒度SizeOfImage是加载后占用的总内存大小Subsystem区分它是控制台程序还是图形界面程序。可选头末尾是数据目录固定 16 项每项 8 字节4 字节 RVA 4 字节大小。这 16 项分别指向导出表、导入表、资源表、异常表、安全目录、重定位表、调试信息、TLS、加载配置、绑定导入、IAT、延迟导入、CLR 头等等。数据目录是整个 PE 的“索引页”你想找什么功能的数据先来这里查地址再换算成文件偏移去读这个套路贯穿所有解析工作。有一个坑必须提前说安全目录第 5 项索引从 0 算起是第 4 项里的地址不是 RVA而是文件偏移。这是 16 项里唯一的例外因为它指向的是附加在文件末尾的签名证书数据不参与内存加载。很多解析库在这里不做特殊处理结果算出来的偏移完全对不上读出一堆无效数据。2.3 节表每个节区的身份证可选头之后是节表NumberOfSections个表项每项 40 字节。结构体IMAGE_SECTION_HEADER的字段包括8 字节名称、VirtualSize内存中实际大小、VirtualAddress内存中的 RVA、SizeOfRawData磁盘上占用大小、PointerToRawData磁盘上的文件偏移、以及末尾 4 字节的Characteristics属性标志。节区名称最长 8 字节不足补零。常见的有.text代码、.data已初始化读写数据、.rdata只读数据包括导入表、字符串常量、.rsrc资源图标、版本信息、对话框、.reloc重定位信息、.bss未初始化数据磁盘上不占空间。但名称只是约定不是强制加壳工具常把节名改成随机字符甚至空名所以永远按属性标志判断节区用途不要按名字判断。属性标志里几个关键位0x20000000表示可执行0x40000000表示可读0x80000000表示可写0x00000020表示包含代码0x00000040表示包含已初始化数据0x02000000表示可丢弃加载后可以释放.reloc常常带这个标志。一个正常的代码段标志通常是0x60000020即“包含代码 可执行 可读”。VirtualSize和SizeOfRawData的关系值得单独说。前者是加载到内存后这个节实际用多少字节后者是磁盘上占多少字节。因为磁盘按FileAlignment对齐内存按SectionAlignment对齐两者往往不等。一般情况下SizeOfRawData会大于等于VirtualSize并按文件对齐向上取整但也存在VirtualSize更大的情况比如某些节在内存中需要额外空间但磁盘上不存储。判断一个 RVA 是否落在某个节内严谨做法是用VirtualSize但很多工具用SizeOfRawData两者在边界上会产生分歧这个细节后面讲地址换算时还会展开。3. 对齐与地址换算九成的解析错误出在这3.1 两种对齐各自的分工FileAlignment通常是 0x200512 字节意思是磁盘上每个节的起始偏移必须是 512 的整数倍。SectionAlignment通常是 0x10004096 字节意思是内存中每个节的起始 RVA 必须是 4096 的整数倍。为什么要分开因为磁盘扇区是 512 字节内存页是 4096 字节两边硬件约束不同映射效率最高的粒度也不同。举个具体例子。假设一个节VirtualAddress 0x1000VirtualSize 0x1234FileAlignment 0x200SectionAlignment 0x1000。那么内存中这个节占据0x1000到0x2234但因为下一个节必须从 4096 对齐的位置开始实际它会被撑到0x2000结束这里0x1234超过了一个页所以占两页。磁盘上SizeOfRawData要按 0x200 向上取整0x1234取整后是0x1400也就是磁盘上这个节占 5120 字节。这种“内存里多占一点、磁盘上多占一点”的差异是加载器映射时按页拷贝造成的。你在磁盘上读0x1400字节的数据加载到内存后有效部分只有0x1234剩下的是零填充。如果你的代码按内存布局去磁盘上找数据就会读到不属于这个节的填充字节解析出来的字符串后面跟着一串零或者结构体字段整体错位。还有一类特殊情况如果SectionAlignment小于 0x1000比如某些驱动或特殊产物设为 0x200那么文件对齐和内存对齐可能相同这时候磁盘布局和内存布局基本一致RVA 直接等于文件偏移。这种文件解析起来最省事但也最容易让人产生“RVA 就是偏移”的错误认知换个正常文件立刻就翻车。3.2 RVA 到文件偏移的换算推导换算逻辑本身不复杂但要写对边界条件。步骤是这样的拿到目标 RVA。遍历节表对每个节判断RVA VirtualAddress且RVA VirtualAddress max(VirtualSize, SizeOfRawData)这里的取大值是为了覆盖两种边界情况。命中后文件偏移 RVA - VirtualAddress PointerToRawData。如果所有节都没命中说明这个 RVA 落在头部区域。头部区域在内存和磁盘上的布局是一致的所以文件偏移 RVA。最后这条“落在头部就用 RVA 当偏移”是很多人忽略的。导入表、导出表这些数据目录项指向的 RVA 有可能落在头部虽然少见但合法如果没有兜底逻辑遍历完节表返回失败你就读不到数据还以为是文件有问题。还有一个更隐蔽的问题节与节之间可能存在空隙。因为每个节按 0x1000 对齐如果上一个节VirtualSize只有0x800那么从0x1800到0x2000这一段在内存中属于“空洞”不属于任何节但加载器依然会映射填充为零。如果一个 RVA 正好落在这个空洞里按上面的遍历逻辑会找不到所属节。这时候正确的处理是把它归到“无效地址”因为正常编译产物不会把有效数据放在节外空隙里但如果遇到手工构造的样本就得特殊对待直接按RVA与最近节的关系估算偏移或者干脆报错。我用 Python 写过一个简易换算函数逻辑就是上面四步跑过几千个样本包括各种加壳的命中率很高。唯一需要额外处理的是某些样本的SizeOfRawData被故意改成 0表示这个节在磁盘上不存储数据类似.bss这时候PointerToRawData也无效换算必然失败要单独判断并跳过。4. 动手实测工具与脚本双线验证4.1 静态工具的使用顺序与取舍看 PE 结构工具不在多在顺序对。我自己的习惯是三件套先用dumpbin /headers快速扫一遍头部和节表确认架构、入口点、节数量再用图形化工具CFF Explorer 或 PEView 这类直观地看数据目录和节区视图它们的树形展开对理解结构帮助很大最后用脚本做批量或精细化提取。dumpbin的好处是命令行、可脚本化、输出稳定。dumpbin /headers 目标文件会把 DOS 头、文件头、可选头、数据目录、节表全部打出来。我一般重点看三行entry point确认入口 RVAimage base确认建议基址section contains the following下面的节列表确认每个节的虚拟地址和文件偏移。如果入口点 RVA 不在任何可执行节的范围内这个文件要么是 DLL入口是DllMain正常要么被改过值得警惕。图形工具的价值在于“所见即所得”。打开一个文件左侧树里点开Optional Header - Data Directories16 项一目了然哪项有值哪项为零清清楚楚。点开Section Headers每个节的原始字节、虚拟地址、属性标志都能看到还能直接跳到对应偏移看十六进制。新手容易犯的错是只看图形工具的汇总视图不去核对原始字节遇到工具解析异常比如节名乱码就懵了。我的建议是图形工具用来建立直觉命令行工具用来做验证两者结论不一致时以原始字节为准。4.2 用脚本把关键字段读出来依赖现成的解析库能省事但第一次接触 PE 时手写一遍解析对理解结构帮助极大。下面这段代码用的是pefile库它封装得比较好字段名和官方结构体基本对应适合快速上手import pefile import sys def dump_pe(path): pe pefile.PE(path, fast_loadTrue) pe.parse_data_directories() print(f文件: {path}) print(f架构: {hex(pe.FILE_HEADER.Machine)}) print(f节数量: {pe.FILE_HEADER.NumberOfSections}) print(f入口点 RVA: {hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint)}) print(f建议基址: {hex(pe.OPTIONAL_HEADER.ImageBase)}) print(f文件对齐: {hex(pe.OPTIONAL_HEADER.FileAlignment)}) print(f节对齐: {hex(pe.OPTIONAL_HEADER.SectionAlignment)}) print(f加载后大小: {hex(pe.OPTIONAL_HEADER.SizeOfImage)}) print(- * 60) for sec in pe.sections: name sec.Name.rstrip(b\x00).decode(utf-8, errorsreplace) print(f节: {name:10} fVA{hex(sec.VirtualAddress):10} fVSize{hex(sec.Misc_VirtualSize):10} fRawPtr{hex(sec.PointerToRawData):10} fRawSize{hex(sec.SizeOfRawData):10} fFlags{hex(sec.Characteristics)}) print(- * 60) if hasattr(pe, DIRECTORY_ENTRY_IMPORT): for entry in pe.DIRECTORY_ENTRY_IMPORT: dll entry.dll.decode(utf-8, errorsreplace) print(f导入模块: {dll} ({len(entry.imports)} 个函数)) else: print(未发现导入表) pe.close() if __name__ __main__: dump_pe(sys.argv[1] if len(sys.argv) 1 else sample.exe)这段代码跑出来的输出和我前面讲的字段能一一对应。重点看节列表那几列VA就是 RVARawPtr就是文件偏移你可以随便挑一个节的VA套用换算公式文件偏移 RVA - VA RawPtr算出来的结果拿去十六进制编辑器里跳转应该正好是这个节的起始字节。如果对不上先检查是不是算错了对齐再检查是不是工具显示的VSize和实际不符。一个实测技巧拿一个自己编译的小程序比如一个只打印 hello 的控制台程序做实验比拿系统目录里那些复杂 exe 更容易理解。自己编译的程序节少、节名规范、没有加壳所有字段都“标准”适合建立基准认知。等你把标准文件读透了再去碰那些节名乱码、节数量爆炸的样本才不会被误导。脚本里我用了fast_loadTrue再手动parse_data_directories()原因是默认加载会解析所有目录遇到某些异常样本可能卡住或者抛异常。先快速加载拿到头部再按需解析目录控制权在自己手里出问题也好定位。另外pe.close()别忘了加文件句柄虽然是只读的但批量处理时句柄泄漏会导致后续文件打不开。4.3 用十六进制视图做最终确认脚本和工具都可能有解析偏差特别是遇到非标准样本。最终的裁判是原始的十六进制字节。我自己定位某个字段时习惯先算出偏移然后去十六进制编辑器里核对那几个字节的值。比如校验文件头里NumberOfSections是不是脚本读出来的值e_lfanew指向 PE 签名签名占 4 字节文件头从e_lfanew 4开始NumberOfSections在文件头里的偏移是 2Machine占 2 字节所以目标位置是e_lfanew 6读 2 字节小端序。这个推导过程写一遍你对结构的记忆会比看十篇文章都牢。再比如找一个节区的原始数据节表起始偏移是e_lfanew 4 20 SizeOfOptionalHeader每个表项 40 字节第 n 个节的PointerToRawData在其内部偏移 20 处。这些偏移量的计算不难但必须自己动手推一遍否则永远是“大概知道”。5. 真实场景里的 PE导入表、重定位与加壳后的变形5.1 导入表为什么总是读不全导入表是 PE 里最常被解析、也最容易出问题的部分。它的结构是三层的数据目录指向一组IMAGE_IMPORT_DESCRIPTOR每个 20 字节以全零项结尾每个描述符通过Name字段指向一个 DLL 名字符串通过OriginalFirstThunk简称 INT指向一组函数名指针通过FirstThunk简称 IAT指向加载后会被填上真实函数地址的数组。新手常见的困惑是为什么 INT 和 IAT 的内容看起来一样因为在磁盘上IAT 还没被填写它的每一项和 INT 一样指向同一个函数名结构或者是一个序号值最高位为 1 表示按序号导入。加载器在绑定时会把 IAT 每一项改写成真实地址所以内存里的 IAT 和磁盘上的 IAT 内容完全不同。这就是为什么“从磁盘读 IAT 想拿函数地址”永远拿不到你拿到的是未绑定的占位值。解析时还要处理一个细节OriginalFirstThunk有可能为 0某些老链接器或手工构造的文件这时候绑定信息只存在于FirstThunk你就得回退到用FirstThunk当 INT 用。这个回退逻辑不加遇到这类文件导入表就解析为空你还以为是文件没导入任何东西。函数名结构也值得说INT 每一项是 8 字节64 位或 4 字节32 位判断最高位是否为 1 来区分“序号导入”和“名称导入”。名称导入时这 4/8 字节是一个 RVA指向一个 2 字节的Hint加一个以零结尾的 ASCII 函数名。很多人只读函数名忘了前面 2 字节的 Hint结果字符串偏移错两位读出来第一个字符是乱码。5.2 重定位表与“为什么我的程序换台机器就跑不起来”如果一个 PE 没有按ImageBase指定的地址加载比如那个地址已经被别的模块占了加载器就得把整个模块挪到别处这时候所有写死的绝对地址都得修正修正的依据就是重定位表。重定位表按块组织每个块头是 8 字节4 字节的页 RVA 和 4 字节的块大小。块头之后是一串 2 字节的重定位项高 4 位是类型低 12 位是页内偏移。类型 3 最常见32 位场景下表示一个 4 字节绝对地址需要修正类型 100xA是 64 位场景对应的类型。修正逻辑是加载时算出实际基址和建议基址的差值 delta然后对每个重定位项指向的位置加上 delta。这件事对普通开发者的意义在于如果你编译时关掉了“生成重定位信息”比如强行固定基址程序就只能加载到指定地址一旦被占用就启动失败。现代系统开了 ASLR地址空间随机化每次加载地址都不同所以重定位表基本是必需的。你在分析样本时如果发现重定位表为空要么这个文件固定了基址、要么被刻意处理过两种情况都值得注意。5.3 加壳之后你看到的 PE 可能只是“壳的 PE”真实世界的样本尤其是安全分析场景里的很多都加过壳。加了壳之后你解析出来的节表、导入表、入口点全都是壳程序的不是原始程序的。壳在运行时会把原始程序在内存里解开再跳过去执行。这时候磁盘上的 PE 结构和内存里展开后的结构差异巨大。识别加壳有几个经验信号节数量异常多或异常少、节名是乱码或者像.UPX0这种明显标识、入口点落在一个可写可执行但内容熵很高的节里、导入表只有极少数几个函数比如只有LoadLibrary和GetProcAddress这是典型的运行时动态解析 API 的做法、SizeOfImage远大于所有节体积之和。遇到这类文件磁盘静态解析只能看个大概要拿内存里的真实结构得在调试器里跑到解壳完成之后再从内存把镜像 dump 出来然后修复导入表和节表。这个过程涉及的东西很多不是本文范围但你要建立这个认知PE 解析工具读的是文件文件不等于程序真实运行时的样子。很多“解析失败”“字段异常”的问题本质是文件本身就被处理过不是你的代码有 bug。6. 边界情况PE32、托管程序与伪 PE6.1 32 位与 64 位可选头的差异PE32 和 PE32 的核心区别在可选头。PE32 的ImageBase是 4 字节PE32 是 8 字节PE32 有BaseOfData字段PE32 删掉了Magic值不同PE32 是0x10BPE32 是0x20B。因为可选头长度不同节表的起始位置也不一样这就是为什么解析时SizeOfOptionalHeader必须从文件头读而不是写死 224。ImageBase的默认值也变了32 位默认0x40000064 位默认0x140000000。这个值只是“建议”实际加载地址由系统决定开了 ASLR 的话。但很多老代码会假设自己加载在默认地址一旦被重定位就出问题这也是为什么 64 位程序更依赖重定位表。数据目录项数在 PE32 里可能超过 16 项有些新版本定义了更多项但标准仍是 16 项多出来的项老解析器会忽略一般不影响主流程。判断时以NumberOfRvaAndSizes字段为准不要硬编码 16。6.2 托管程序看起来是 PE其实是另一套用 .NET 编译出来的程序也是 PE 格式但它的代码不是原生机器码而是中间语言IL。它的入口点通常指向一小段桩代码真正的元数据和 IL 在 CLR 头数据目录第 15 项索引 14里。你用普通 PE 解析器看会觉得代码段内容看不懂、入口点跳转逻辑奇怪这不是文件坏了是它本来就这样。判断方法很简单看数据目录里 CLR 头那一项是不是非零。非零就是托管程序后续分析得换工具比如 IL 反汇编器拿原生 PE 的眼光去看会处处别扭。这个认知对排查“为什么我的程序在某些机器上提示缺少运行库”很有帮助——因为托管程序依赖对应版本的运行时而原生 PE 只依赖系统 DLL。还有一类“伪 PE”文件头写了 MZ 和 PE 签名但内部结构一团糟。这可能是损坏的文件、伪装成 exe 的其他数据、或者是被截断的样本。处理这类文件时第一步永远是校验e_lfanew是否在文件大小范围内、签名是否正确、节表是否越界。这些基本校验不做后面所有解析都是在错误数据上继续结论必然错。6.3 一个容易忽略的边界文件尾部附加数据PE 文件并不要求文件在最后一个节之后立刻结束。很多程序会在尾部附加数据比如安装包把压缩资源追加在后面、数字签名证书附加在末尾、有些保护方案把校验数据藏在尾部。解析时如果用“节表最后一个节的偏移加大小”当作文件有效长度就会漏掉这部分数据。反过来做文件完整性校验或者特征提取时尾部附加数据往往是关键信息。我的做法是解析完结构后比较文件实际大小和“最后一个节的文件偏移 大小”如果前者明显更大就把差值部分单独提取出来看一眼。这个习惯帮我发现过好几次隐藏在尾部的配置数据也避免过把正常签名误判为异常。到这里PE 的骨架、地址换算、工具验证、常见场景和边界情况都过了一遍。我个人在实际操作中的体会是PE 结构本身并不复杂难的是那些“不标准”的文件而恰恰是这些不标准的文件最有分析价值。所以别指望一套代码通吃所有样本把基本结构吃透、把校验做足、遇到异常先怀疑文件再怀疑代码这条路走得最稳。手边随时留一个自己编译的标准样本做对照比什么文档都好使。