2026/10/21 4:16:46

PE文件格式详解:从DOS头到节表的二进制分析基础

PE文件格式详解:从DOS头到节表的二进制分析基础 拿到一个二进制文件很多人习惯先拖进工具里看字符串、看导入表但我建议你先别急把PE文件格式当一张“地图”来读。PE格式是Windows下可执行程序、动态库、驱动程序、甚至部分固件的通用骨架搞逆向、做恶意代码分析、写加载器、做内存补丁的人都绕不开它。这篇是这个系列的第一篇我会把PE的整体布局、DOS头、NT头、节表这些基础结构掰开揉碎地讲清楚同时附带一套我自己常用的十六进制排查流程适合刚入门的同学也适合平时只跟工具打交道、却想补底子的朋友。1. 为什么二进制分析的第一课总是从PE结构开始1.1 它解决的是“文件在内存里长什么样”的问题PEPortable Executable的全称叫“可移植可执行体”它描述的不只是磁盘上的文件布局更重要的是程序被加载进内存之后各个部分应该放在哪里、按什么权限放置、入口点在哪、依赖哪些外部函数。看透PE结构你就能回答三个核心问题这个程序是什么架构的它从哪里开始执行它的代码和数据分别放在哪块区域很多人觉得“我有工具直接看导入表、看反汇编不就行了”但工具只是帮你把二进制字段翻译成人话如果不懂字段本身的含义遇到稍微畸形一点、加壳一点、被混淆过的文件工具给出的结果可能完全是错的。更现实的一个场景是你想修改程序的某个字符串或者把导入表重定向到自己的函数如果不知道节表里VirtualAddress和PointerToRawData的关系改出来的文件大概率直接崩掉。1.2 哪些实际工作离不开PE知识我能想到的、日常比较高频的场景有这么几类静态分析拿到一个未知样本先看入口点、看节表权限、看导入导出表快速判断加壳类型和大致行为。加壳与脱壳壳的本质就是压缩或加密原始PE结构然后在运行时重建内存映像。不理解PE的内存布局脱壳时连OEP都找不到。程序劫持与注入修改IAT、HOOK导入函数、追加节区这些操作全部建立在“知道PE结构怎么改”的基础上。资源修改替换图标、版本信息、对话框资源说到底也是在PE资源目录里做增删改查。加载器编写不管是自己写一个内存加载器还是做反射注入本质都是手工模拟系统加载PE的过程。这些场景没有一个是能靠“背字段名”解决的你得在脑子里形成一张从磁盘到内存的映射图。1.3 这篇和后续内容的关系系列第一篇我聚焦在“地基”上整体布局、文件对齐与内存对齐、DOS头、NT头、节表。第二篇再进入数据目录的细节尤其是导入表和导出表这两张表是多数分析工作的入口。后面如果继续写还会涉及重定位表、资源目录、TLS回调、异常表这些进阶内容。所以这一篇哪怕你觉得有些字段暂时用不上也建议先过一遍后面提到时你有印象上手会快很多。2. 动手前先把整体布局和两个“对齐”搞清楚2.1 一张图看完PE的组成部分PE文件从起始偏移0x00一直到文件末尾大致可以划分成这么几块区域区域偏移范围作用DOS头文件起始处64字节保留MZ标记和指向NT头的偏移DOS StubDOS头之后大小不固定老系统兼容用的程序平时可忽略NT头e_lfanew指向的位置包含PE签名、文件头、可选头节表NT头之后描述每个节区的名称、大小、位置、属性节区数据节表之后实际的代码、数据、资源等内容这里要特别提醒一点NT头里的“可选头”名字叫Optional但它一点都不“可选”所有PE文件都必须有这个头。它叫可选只是历史遗留因为COFF文件格式里的某些信息不一定要有到了PE里这整个结构变成固定必备的了。2.2 文件对齐和内存对齐这个坑必须先跳过PE文件有两个对齐概念磁盘上的文件按FileAlignment对齐一般是0x200512字节内存中的映像按SectionAlignment对齐32位程序通常是0x10004096字节64位程序也通常是0x1000。为什么会有两个对齐因为磁盘和内存的“搬运单位”不同磁盘按扇区读写内存按页映射系统希望加载时尽量减少映射页的数量所以把内存里的节区放到整页边界。举个直观的例子一个节区在磁盘上可能只占0x1C0字节但文件里的SizeOfRawData会写成0x200整多出来的区域用零填充。读进内存后这个节区的VirtualSize仍写成0x1C0但映射进去的页却占据0x1000大小节区与节区之间在内存里紧挨着排列。这就是为什么很多PE文件实际大小并不等于各节区原始大小之和那个差值就是对齐填充带来的“膨胀”。注意修改PE时最容易犯的错就是把新加节区写得跟实际大小一模一样没有做对齐。结果文件一多写几个节区系统加载时直接报“不是有效的应用程序”。我建议所有新增内容都按0x200对齐来算宁可多填几十个字节的零也不要让SizeOfRawData出现非对齐值。2.3 RVA和文件偏移这是理解一切的换算枢纽工具显示“Address: 0x401000”可你在文件里滚了半天也找不到对应的字节就是因为那是一个内存地址。我们平时分析PE文件最常看到三个概念VA虚拟地址、RVA相对虚拟地址、File Offset文件偏移。VA是程序运行时的绝对地址等于ImageBase加上RVA。RVA是相对于镜像基址的偏移例如入口点AddressOfEntryPoint字段存的是0x16A0那么实际入口地址就是ImageBase 0x16A0。File Offset是数据在磁盘文件中的偏移。从文件偏移算RVA必须知道那个偏移属于哪个节区。基本公式是RVA 节区在内存中的起始RVA (文件偏移 - 该节区的PointerToRawData)。很多工具脚本算出来对不上基本都是在“该偏移属于哪个节”这个环节出了偏差比如直接拿文件偏移减ImageBase那肯定是错的。3. DOS头和NT头前几十个字节里全是关键开关3.1 DOS头到底还有什么用DOS头一共64字节结构体定义里字段多但绝大多数在现代PE里已经是废字段。你需要记住的就两个e_magic文件头两个字节必须是0x5A4D也就是ASCII字符“MZ”。它是对“这是一个PE文件”的第一层校验很多样本就是直接改掉这个标志来干扰工具的遇到报错先看一眼是不是这里被动过。e_lfanew偏移0x3C处的4字节存的是一个文件偏移值指向真正的NT头。它是整个PE解析的“导航坐标”。用十六进制编辑器打开一个文件先跳到文件末尾的PE头位置这是最快找到NT头的方法。DOS头后面通常跟着一段DOS Stub是一小段能在DOS下运行的程序对于现代Windows分析几乎不需要关注只要知道它存在、且它的存在会让NT头的偏移不等于固定值即可。3.2 IMAGE_FILE_HEADER机器类型、节数量、特征值从e_lfanew指向的位置开始先是4字节的PE签名“\x50\x45\x00\x00”也就是“PE\0\0”。签名之后就是IMAGE_FILE_HEADER20字节。我常用的字段只有四个Machine标识目标架构。0x14C表示x860x8664表示x640xAA64是ARM640x1C0是ARM。如果这个值和系统位数对不上加载会直接失败。NumberOfSections文件中节区总数。后面解析节表要用它做循环次数所以这个值别信工具展示自己确认一下更稳妥。SizeOfOptionalHeader紧跟其后的可选头大小。32位PE通常是0xE064位是0xF0。如果遇到非标准值多半是编译器或加壳器改过。Characteristics一组按位组合的标志表示文件属性。比较重要的是0x2002可执行文件和0x200EDLL。这三个值在排查很多“莫名其妙的加载失败”时特别有用比如NumberOfSections被清成0很多工具就会漏解析后面的节表。3.3 IMAGE_OPTIONAL_HEADER里你该盯住的字段可选头分32位和64位两种结构上有差异但核心字段一致。最实用的几个Magic0x10B表示PE320x20B表示PE32也就是64位程序。这个字段决定后面字段的位宽。AddressOfEntryPoint入口点RVA。程序真正开始执行的代码位置。脱壳、找OEP全靠它。ImageBase程序在内存中的首选加载地址。EXE一般是0x400000或0x140000000DLL一般是0x10000000。注意这是“首选”实际运行时不保证就是这个系统可能重定位。SectionAlignment和FileAlignment前面说过的两个对齐值解析节区时要用。SizeOfImage内存映像总大小必是SectionAlignment的整数倍。改节区时不更新这个值加载器会认为文件不完整。SizeOfHeaders所有头加节表的总大小按FileAlignment对齐。这里其实是DOS头 DOS Stub NT头 节表的总长度通常就是0x400或0x200。Subsystem标识程序类型1是驱动Native2是GUI3是CUI命令行。如果工具识别出来的子系统和你看到的文件行为对不上说明被改过。NumberOfRvaAndSizes和DataDirectory数据目录数组这个数组就是导入表、导出表、资源表等所有关键表的“索引目录”每一项8字节前4字节是RVA后4字节是大小。虽然很多文件写16项但Windows只使用前15项第16项是给未来的预留。提示AddressOfEntryPoint只是一个RVA不是完整地址。很多刚接触的朋友看到0x16A0就去找文件偏移0x16A0找不到或者找到不对应的数据就疑惑“文件是不是坏了”。正确做法是先判断这个RVA落在哪个节区内再转换为文件偏移。忘了这一步后面全是乱套的。4. 数据目录和节表真正决定程序“干什么活”的地方4.1 数据目录是一个数组不是一张表NT头里的DataDirectory是固定大小的数组它并不存储实际的导入函数、导出函数列表而是存这些数据所在的位置和大小。编号0是导出表1是导入表2是资源表3是异常表4是安全表5是重定位表6是调试信息9是导入函数地址表IAT。每个表的重要性不一样但最常用的就是导入表和导出表。分析时你可以只关心某个特定表的RVA。比如导入表工具会显示“Import Directory RVA: 0x1000”然后你要去0x1000对应的文件偏移找IMAGE_IMPORT_DESCRIPTOR结构数组。这个数组每项20字节最后以一项全零结束。之前遇到过有人拿着数据目录的RVA当成文件偏移去读读出来一行乱码那种情况就是没有做RVA到文件偏移的转换。4.2 节表五兄弟名字、大小、地址、偏移、权限节表从NT头结束位置开始每项40字节个数由NumberOfSections决定。每一项IMAGE_SECTION_HEADER里最关键的五个字段Name8字节节名不一定以零结尾。常见的有.text、.data、.rdata、.idata、.rsrc等但加壳器经常改名字所以名字只能作为参考不能作为判断依据。VirtualSize节区在内存中的实际大小不含对齐。VirtualAddress节区加载到内存后相对于ImageBase的RVA。SizeOfRawData节区在磁盘文件上占用的字节数取值是对齐后的通常大于等于VirtualSize。PointerToRawData节区数据在文件中的起始偏移。Characteristics权限和属性标志下面单独说。节表是磁盘和内存之间的“翻译表”。拿到任何一个文件偏移想要知道它对应内存里的哪个位置必须先看它落在哪个节区的[PointerToRawData, PointerToRawData SizeOfRawData)范围内然后套公式换算。反过来也一样把内存RVA转换成文件偏移同样要查节表。4.3 节的权限标志可读、可写、可执行不是默认选项节区的Characteristics是按位组合的权限一般最常用三个值0x60000020表示可读可写数据节0x40000040表示可读可执行代码节0xE0000040表示可读可写可执行的节这类节在“加壳”程序里很常见因为壳需要动态生成代码。一个经验之谈正常编译出来的程序代码节通常是可读可执行的不会带可写权限数据节通常是可读可写的不会带执行权限。如果你看到一个文件的节区同时具备“可写可执行”又怀疑它有可疑行为那这个节区十有八九是被用来承载动态生成代码或自修改代码的。这在恶意代码分析里是重点盯防的特征。5. 手把手实操用十六进制视角还原一个最小EXE5.1 准备一个干净的样例先准备一个最干净、最简单的样例。我不会在这里推荐具体的编译器品牌你用自己习惯的工具链就行只要能生成一个正常的64位EXE即可。随便写一个C程序里面就一个main函数返回0然后编译成Release版本。编译完的文件应该非常小适合拿来练手。然后把文件用十六进制编辑器打开。这类工具很多我用得最多的是010 Editor和一套自己的十六进制浏览脚本但正规一点的十六进制编辑器都能胜任。打开之后先在文件头确认前两个字节是“4D 5A”。5.2 逐字节校验从e_magic到节表第一步看文件头前两个字节确认是“4D 5A”即MZ。如果打开发现不是这两个字节直接放弃这不是一个有效的PE文件或至少不是一个按标准布局存储的文件。第二步把光标移到偏移0x3C处读出4字节这就是e_lfanew。这里要特别注意字节序是小端存储比如看到“00 20 00 00”那实际值是0x2000。小端这个概念特别容易被忽略可每次出错基本都是卡在这。第三步跳到0x2000这个位置应该看到“50 45 00 00”也就是PE签名。紧接着20字节就是IMAGE_FILE_HEADER。你可以对照前面讲过的Machine判断架构。第四步算出OptionalHeader的起始位置也就是NT头起始 4 20。64位程序在0x2000 4 20 0x2018然后Magic是“0B 02”也就是0x20B是个64位程序。 AddressOfEntryPoint在64位头的偏移16处如果你看到一个值比如0x16A0先不要拿它当文件偏移用。第五步找到节表位置。NT头总大小是4 20 0xF0 0x118从0x2000开始算节表起始是0x2118。前面NumberOffSections字段如果是3那就解析3项每项40字节到0x2190之前全是节表。有一个非常实用的估算SizeOfHeaders字段一般在0x400也就是说DOS头NT头节表加一起大概0x400字节所以前1024字节里能看完整所有头信息。如果你看到一个文件前0x400里就塞了海量数据说明DOS Stub被塞了代码这在被感染的样本里经常见。5.3 用解析脚本和工具交叉验证手看十六进制虽然直观但效率低通常我会配合一个简单的Python脚本做快速解析。下面是一个我自己经常改着用的最小解析脚本只输出最关键的头部信息不依赖第三方库import struct def parse_pe(path): with open(path, rb) as f: data f.read() if data[:2] ! bMZ: print(Not a valid MZ file) return e_lfanew struct.unpack(I, data[0x3C:0x40])[0] if data[e_lfanew:e_lfanew4] ! bPE\x00\x00: print(PE signature not found) return machine struct.unpack(H, data[e_lfanew4:e_lfanew6])[0] num_sections struct.unpack(H, data[e_lfanew6:e_lfanew8])[0] size_opt struct.unpack(H, data[e_lfanew20:e_lfanew22])[0] opt_start e_lfanew 4 20 magic struct.unpack(H, data[opt_start:opt_start2])[0] is64 magic 0x20B entry_rva struct.unpack(I, data[opt_start16:opt_start20])[0] image_base struct.unpack(Q if is64 else I, data[opt_start24:opt_start32])[0] if is64 else struct.unpack(I, data[opt_start28:opt_start32])[0] print(fMachine: 0x{machine:04X}, Sections: {num_sections}, OptionalHeaderSize: 0x{size_opt:X}) print(fMagic: 0x{magic:04X}, EntryPointRVA: 0x{entry_rva:X}, ImageBase: 0x{image_base:X}) sec_start opt_start size_opt for i in range(num_sections): off sec_start i * 40 name data[off:off8].rstrip(b\x00).decode(ascii, errorsreplace) vsize, vaddr, raw_size, raw_ptr struct.unpack(IIII, data[off8:off24]) print(fSection {i}: {name}, VirtualSize0x{vsize:X}, VirtualAddr0x{vaddr:X}, RawSize0x{raw_size:X}, RawPtr0x{raw_ptr:X}) parse_pe(rC:\temp\hello.exe)这个脚本的输出会和你用十六进制编辑器手动确认的结果完全一致。只要有一项不一致就说明中间某个偏移算错了。我会把脚本输出和PE查看工具的结果放一起对比工具显示的数据是“权威答案”但脚本能告诉你这个答案是怎么来的。交叉验证这一步特别重要。很多人学PE感觉记不住字段核心原因是没有亲手把一个文件从头到尾“翻译”过一遍。一旦你亲自算出从e_lfanew到节表偏移这个偏移关系就刻在脑子里了比看十篇文章都管用。6. 拆PE时最常见的几个“想当然”和排查技巧6.1 入口点RVA到底要不要减ImageBase这个问题我见过无数人纠结。AddressOfEntryPoint存的是RVA不是完整的VA。比如值0x16A0它的完整入口地址是ImageBase 0x16A0。很多调试器显示入口地址时会自动加上ImageBase所以你在调试器里看到0x1400016A0但文件里存的却是0x16A0这是正常现象。反过来如果你想从调试器显示的入口地址反推出文件里的入口点就要先减掉ImageBase得到RVA再根据节表做换算。曾经看到有人直接用入口VA减去0x400再当成文件偏移去文件里找出来的东西永远是错的原因就是没考虑节表映射。6.2 VirtualSize和SizeOfRawData不一致时要信谁正常PE文件里VirtualSize和SizeOfRawData不一定相等常见情况是VirtualSize小于SizeOfRawData这是对齐造成的。但有些加壳或手工修改过的文件会出现VirtualSize大于SizeOfRawData的情况这时数据在内存里有一部分是从文件中读不到的系统会以零填充。分析时如果一个节区的实际情况是“内存里能看到数据、文件里却没有”不要惊讶这不是文件损坏而是合法的PE特性。但要留个心眼恶意样本经常利用这种方式把恶意代码藏在内存中只在运行后展开。6.3 为什么有的节区在文件里找不到完整数据除了对齐填充还有一类情况叫“空节区”。有些节区的VirtualSize是0但文件里却有一大块数据或者反过来VirtualAddress很大但文件里对应偏移却找不到任何实际内容。这类畸形PE主要来自两类情况一是某些编译器对未使用节区做了特殊优化二是人工或工具构造的“伪装文件”。遇到这种情况不要迷信工具的单字段展示把节表的原始十六进制拉出来看一遍往往能发现工具没告诉你的东西。6.4 常见问题速查表现象原因排查方法打开文件报“不是有效的Win32程序”DOS头或PE签名被破坏检查前两个字节是否为MZe_lfanew处是否为PE\0\0工具显示的入口点和调试器不一致一个显示RVA一个显示VA统一用RVA或者加上ImageBase再看修改字符串后程序崩溃改写的字符串跨节区边界导致RVA换算错误确认目标字符串的RVA落在哪个节区节区大小是否够用新增节区后文件加载失败没有按FileAlignment对齐或SizeOfImage没更新新节区按0x200对齐SizeOfImage按SectionAlignment对齐工具能读双击不能运行文件头和实际内容不匹配常见于手工修改用脚本对照解析找出第一个对不上的字段这篇先到这第二篇往哪走这一篇的内容已经不少了但PE的分析绝不只是看头。头部的信息只是在告诉你“文件往哪走”真正决定“程序干什么”的是那些数据目录指向的表。我踩过最深的坑之一就是解析导入表时忘了它是一张“由多个IMAGE_IMPORT_DESCRIPTOR组成的数组”导致把第一个描述符后面的全零项当成了结束符多读了一大段无用数据。第二篇我会把导入表、导出表的解析过程完整展开包括如何通过IAT定位API调用、如何处理DLL的延迟加载以及遇到重定位表时该怎么快速判断哪些地址需要修正。你在自己拆文件时如果发现和我这边不一致的地方先别急着怀疑工具按文里的流程走一遍基本都能找到问题出在哪。