拿到一个二进制文件,很多人习惯先拖进工具里看字符串、看导入表,但我建议你先别急,把PE文件格式当一张“地图”来读。PE格式是Windows下可执行程序、动态库、驱动程序、甚至部分固件的通用骨架,搞逆向、做恶意代码分析、写加载器、做内存补丁的人都绕不开它。这篇是这个系列的第一篇,我会把PE的整体布局、DOS头、NT头、节表这些基础结构掰开揉碎地讲清楚,同时附带一套我自己常用的十六进制排查流程,适合刚入门的同学,也适合平时只跟工具打交道、却想补底子的朋友。1. 为什么二进制分析的第一课,总是从PE结构开始1.1 它解决的是“文件在内存里长什么样”的问题PE(Portable 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对齐,一般是0x200(512字节);内存中的映像按SectionAlignment对齐,32位程序通常是0x1000(4096字节),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_HEADER,20字节。我常用的字段只有四个:Machine:标识目标架构。0x14C表示x86,0x8664表示x64,0xAA64是ARM64,0x1C0是ARM。如果这个值和系统位数对不上,加载会直接失败。NumberOfSections:文件中节区总数。后面解析节表要用它做循环次数,所以这个值别信工具展示,自己确认一下更稳妥。SizeOfOptionalHeader:紧跟其后的可选头大小。32位PE通常是0xE0,64位是0xF0。如果遇到非标准值,多半是编译器或加壳器改过。Characteristics:一组按位组合的标志,表示文件属性。比较重要的是0x2002(可执行文件)和0x200E(DLL)。这三个值在排查很多“莫名其妙的加载失败”时特别有用,比如NumberOfSections被清成0,很多工具就会漏解析后面的节表。3.3 IMAGE_OPTIONAL_HEADER里你该盯住的字段可选头分32位和64位两种,结构上有差异,但核心字段一致。最实用的几个:Magic:0x10B表示PE32,0x20B表示PE32+,也就是64位程序。这个字段决定后面字段的位宽。AddressOfEntryPoint:入口点RVA。程序真正开始执行的代码位置。脱壳、找OEP全靠它。ImageBase:程序在内存中的首选加载地址。EXE一般是0x400000或0x140000000,DLL一般是0x10000000。注意这是“首选”,实际运行时不保证就是这个,系统可能重定位。SectionAlignment和FileAlignment:前面说过的两个对齐值,解析节区时要用。SizeOfImage:内存映像总大小,必是SectionAlignment的整数倍。改节区时不更新这个值,加载器会认为文件不完整。SizeOfHeaders:所有头加节表的总大小,按FileAlignment对齐。这里其实是DOS头 + DOS Stub + NT头 + 节表的总长度,通常就是0x400或0x200。Subsystem:标识程序类型,1是驱动(Native),2是GUI,3是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里,最关键的五个字段:Name:8字节节名,不一定以零结尾。常见的有.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] != b'MZ': print('Not a valid MZ file') return e_lfanew = struct.unpack('<I', data[0x3C:0x40])[0] if data[e_lfanew:e_lfanew+4] != b'PE\x00\x00': print('PE signature not found') return machine = struct.unpack('<H', data[e_lfanew+4:e_lfanew+6])[0] num_sections = struct.unpack('<H', data[e_lfanew+6:e_lfanew+8])[0] size_opt = struct.unpack('<H', data[e_lfanew+20:e_lfanew+22])[0] opt_start = e_lfanew + 4 + 20 magic = struct.unpack('<H', data[opt_start:opt_start+2])[0] is64 = magic == 0x20B entry_rva = struct.unpack('<I', data[opt_start+16:opt_start+20])[0] image_base = struct.unpack('<Q' if is64 else '<I', data[opt_start+24:opt_start+32])[0] if is64 else struct.unpack('<I', data[opt_start+28:opt_start+32])[0] print(f'Machine: 0x{machine:04X}, Sections: {num_sections}, OptionalHeaderSize: 0x{size_opt:X}') print(f'Magic: 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:off+8].rstrip(b'\x00').decode('ascii', errors='replace') vsize, vaddr, raw_size, raw_ptr = struct.unpack('<IIII', data[off+8:off+24]) print(f'Section {i}: {name}, VirtualSize=0x{vsize:X}, VirtualAddr=0x{vaddr:X}, RawSize=0x{raw_size:X}, RawPtr=0x{raw_ptr:X}') parse_pe(r'C:\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签名被破坏检查前两个字节是否为MZ,e_lfanew处是否为PE\0\0工具显示的入口点和调试器不一致一个显示RVA,一个显示VA统一用RVA,或者加上ImageBase再看修改字符串后程序崩溃改写的字符串跨节区边界,导致RVA换算错误确认目标字符串的RVA落在哪个节区,节区大小是否够用新增节区后文件加载失败没有按FileAlignment对齐,或SizeOfImage没更新新节区按0x200对齐,SizeOfImage按SectionAlignment对齐工具能读,双击不能运行文件头和实际内容不匹配,常见于手工修改用脚本对照解析,找出第一个对不上的字段这篇先到这,第二篇往哪走这一篇的内容已经不少了,但PE的分析绝不只是看头。头部的信息只是在告诉你“文件往哪走”,真正决定“程序干什么”的,是那些数据目录指向的表。我踩过最深的坑之一,就是解析导入表时忘了它是一张“由多个IMAGE_IMPORT_DESCRIPTOR组成的数组”,导致把第一个描述符后面的全零项当成了结束符,多读了一大段无用数据。第二篇我会把导入表、导出表的解析过程完整展开,包括如何通过IAT定位API调用、如何处理DLL的延迟加载,以及遇到重定位表时该怎么快速判断哪些地址需要修正。你在自己拆文件时如果发现和我这边不一致的地方,先别急着怀疑工具,按文里的流程走一遍,基本都能找到问题出在哪。
拿到一个二进制文件,很多人习惯先拖进工具里看字符串、看导入表,但我建议你先别急,把PE文件格式当一张“地图”来读。PE格式是Windows下可执行程序、动态库、驱动程序、甚至部分固件的通用骨架,搞逆向、做恶意代码分析、写加载器、做内存补丁的人都绕不开它。这篇是这个系列的第一篇,我会把PE的整体布局、DOS头、NT头、节表这些基础结构掰开揉碎地讲清楚,同时附带一套我自己常用的十六进制排查流程,适合刚入门的同学,也适合平时只跟工具打交道、却想补底子的朋友。1. 为什么二进制分析的第一课,总是从PE结构开始1.1 它解决的是“文件在内存里长什么样”的问题PE(Portable 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对齐,一般是0x200(512字节);内存中的映像按SectionAlignment对齐,32位程序通常是0x1000(4096字节),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_HEADER,20字节。我常用的字段只有四个:Machine:标识目标架构。0x14C表示x86,0x8664表示x64,0xAA64是ARM64,0x1C0是ARM。如果这个值和系统位数对不上,加载会直接失败。NumberOfSections:文件中节区总数。后面解析节表要用它做循环次数,所以这个值别信工具展示,自己确认一下更稳妥。SizeOfOptionalHeader:紧跟其后的可选头大小。32位PE通常是0xE0,64位是0xF0。如果遇到非标准值,多半是编译器或加壳器改过。Characteristics:一组按位组合的标志,表示文件属性。比较重要的是0x2002(可执行文件)和0x200E(DLL)。这三个值在排查很多“莫名其妙的加载失败”时特别有用,比如NumberOfSections被清成0,很多工具就会漏解析后面的节表。3.3 IMAGE_OPTIONAL_HEADER里你该盯住的字段可选头分32位和64位两种,结构上有差异,但核心字段一致。最实用的几个:Magic:0x10B表示PE32,0x20B表示PE32+,也就是64位程序。这个字段决定后面字段的位宽。AddressOfEntryPoint:入口点RVA。程序真正开始执行的代码位置。脱壳、找OEP全靠它。ImageBase:程序在内存中的首选加载地址。EXE一般是0x400000或0x140000000,DLL一般是0x10000000。注意这是“首选”,实际运行时不保证就是这个,系统可能重定位。SectionAlignment和FileAlignment:前面说过的两个对齐值,解析节区时要用。SizeOfImage:内存映像总大小,必是SectionAlignment的整数倍。改节区时不更新这个值,加载器会认为文件不完整。SizeOfHeaders:所有头加节表的总大小,按FileAlignment对齐。这里其实是DOS头 + DOS Stub + NT头 + 节表的总长度,通常就是0x400或0x200。Subsystem:标识程序类型,1是驱动(Native),2是GUI,3是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里,最关键的五个字段:Name:8字节节名,不一定以零结尾。常见的有.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] != b'MZ': print('Not a valid MZ file') return e_lfanew = struct.unpack('<I', data[0x3C:0x40])[0] if data[e_lfanew:e_lfanew+4] != b'PE\x00\x00': print('PE signature not found') return machine = struct.unpack('<H', data[e_lfanew+4:e_lfanew+6])[0] num_sections = struct.unpack('<H', data[e_lfanew+6:e_lfanew+8])[0] size_opt = struct.unpack('<H', data[e_lfanew+20:e_lfanew+22])[0] opt_start = e_lfanew + 4 + 20 magic = struct.unpack('<H', data[opt_start:opt_start+2])[0] is64 = magic == 0x20B entry_rva = struct.unpack('<I', data[opt_start+16:opt_start+20])[0] image_base = struct.unpack('<Q' if is64 else '<I', data[opt_start+24:opt_start+32])[0] if is64 else struct.unpack('<I', data[opt_start+28:opt_start+32])[0] print(f'Machine: 0x{machine:04X}, Sections: {num_sections}, OptionalHeaderSize: 0x{size_opt:X}') print(f'Magic: 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:off+8].rstrip(b'\x00').decode('ascii', errors='replace') vsize, vaddr, raw_size, raw_ptr = struct.unpack('<IIII', data[off+8:off+24]) print(f'Section {i}: {name}, VirtualSize=0x{vsize:X}, VirtualAddr=0x{vaddr:X}, RawSize=0x{raw_size:X}, RawPtr=0x{raw_ptr:X}') parse_pe(r'C:\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签名被破坏检查前两个字节是否为MZ,e_lfanew处是否为PE\0\0工具显示的入口点和调试器不一致一个显示RVA,一个显示VA统一用RVA,或者加上ImageBase再看修改字符串后程序崩溃改写的字符串跨节区边界,导致RVA换算错误确认目标字符串的RVA落在哪个节区,节区大小是否够用新增节区后文件加载失败没有按FileAlignment对齐,或SizeOfImage没更新新节区按0x200对齐,SizeOfImage按SectionAlignment对齐工具能读,双击不能运行文件头和实际内容不匹配,常见于手工修改用脚本对照解析,找出第一个对不上的字段这篇先到这,第二篇往哪走这一篇的内容已经不少了,但PE的分析绝不只是看头。头部的信息只是在告诉你“文件往哪走”,真正决定“程序干什么”的,是那些数据目录指向的表。我踩过最深的坑之一,就是解析导入表时忘了它是一张“由多个IMAGE_IMPORT_DESCRIPTOR组成的数组”,导致把第一个描述符后面的全零项当成了结束符,多读了一大段无用数据。第二篇我会把导入表、导出表的解析过程完整展开,包括如何通过IAT定位API调用、如何处理DLL的延迟加载,以及遇到重定位表时该怎么快速判断哪些地址需要修正。你在自己拆文件时如果发现和我这边不一致的地方,先别急着怀疑工具,按文里的流程走一遍,基本都能找到问题出在哪。