PE文件格式解析与PE TOOLS实战:从手写解析到批量筛查
简介这是一份面向PE格式学习与研究者的轻量工具集主要解决可执行文件内部结构查看、分析与修改的需求适用于软件开发者、逆向工程师及系统管理员进行格式解析、调试排查与安全审计。压缩包共83个文件包含17个DLL如PE嗅探、重定位重建库、10个EXE主程序及配套插件另有少量源代码、头文件、汇编工程和脚本既可直接运行也可参考开发整体约301KB非常便携。目前已有809人学习下载。除主程序PETools外压缩包还提供文件识别、进程信息查询、内存转储、签名验证、UPX脱壳等辅助工具搭配SDK与示例工程可深入观察文件头、节区表、导入导出表、重定位表等关键结构。借助这些工具读者能快速定位PE文件异常、验证依赖关系与数字签名并为逆向工程、恶意代码分析或底层开发打下实用基础是一套短小但覆盖全面的PE格式研究资料。1. 先搞清 PE TOOLS 到底在解决什么问题拿到一个来路不明的 exe干这行的第一反应不是双击而是先用 PE TOOLS 看清它的结构。PE TOOLS 这类工具干的事很聚焦把 Windows 可执行文件的 PE 格式解析成人能读的树形结构告诉你文件是 32 位还是 64 位、入口点落在哪个节、有没有加壳痕迹、导入了哪些系统 API。别小看这一步判断样本是否正常、排查 dll 是否被篡改、定位启动崩溃都从这份结构信息开始。文章面向两类读者想自己写 PE 解析器的开发者和需要批量初筛可执行文件的从业者。下面按格式长什么样、手写解析、批量筛查、避坑、进阶的顺序把 PE TOOLS 从入门到能进生产流程讲透。2. PE 格式的骨架DOS 头、NT 头与节表怎么排布2.1 为什么 PE 格式值得被单独做成工具PE 格式的全称是 Portable ExecutableWindows 平台上所有 exe、dll、sys 文件都按这套结构组织。它的设计目标很实在让加载器在运行前就知道文件里有哪些代码、哪些数据、哪些资源以及它们在内存里应该放在什么位置。既然加载器按这个结构读那分析者反着读一遍就能还原出文件的大部分行为特征这就是 PE 工具存在的根基。直接开十六进制编辑器读当然也能读但 PE 头字段是交叉存放的4 字节一个入口点、2 字节一个机器类型、8 字节一个节名手动数偏移很容易看错位。更麻烦的是字段本身只是整数很多要结合上下文才能解释。比如 Machine 字段的 0x14C 和 0x8664 分别代表 x86 和 x64不查表根本记不住。PE 工具把字段名、偏移、值和含义一次性展示出来让分析从对着十六进制数数变成对着结构核对。2.2 DOS 头是入口e_lfanew 是钥匙PE 文件的起点不是 PE 头而是 DOS 头。这是历史遗留DOS 时代的加载器遇到不认识的程序会提示无法在 DOS 模式运行所以每个 Windows 可执行文件开头都保留了 64 字节的 DOS 头第一个字段 e_magic 固定是 ASCII 字符 MZ所有合法 PE 文件都以这两个字节开头。真正关键的是 DOS 头里的 e_lfanew 字段它位于文件偏移 0x3C 处占 4 字节存的是 PE 头在文件里的绝对偏移。解析任何 PE 文件的第一步就是读这个值、跳到对应位置、再校验接下来的 4 字节是不是 PE 签名。把 e_lfanew 理解成钥匙一点不过分整个解析流程都从这一跳开始。网上很多简化脚本喜欢把 PE 头偏移写死成 0x80对小文件可能碰巧能跑换一个带数字签名的文件就直接翻车原因就是没有走 e_lfanew 这条路。字段偏移大小含义e_magic0x002 字节固定为 MZe_lfanew0x3C4 字节PE 头在文件中的偏移2.3 COFF 文件头机器类型、节数量与特征值在 e_lfanew 指向的位置读到 PE 签名后紧接着的是 COFF 文件头固定 20 字节。它记录了机器类型、节数量、时间戳、可选头大小和整体特征值是解析后续结构的路标。Machine 告诉你 CPU 架构NumberOfSections 决定后面要遍历多少个节SizeOfOptionalHeader 决定可选头占多少字节间接决定节表从哪开始。字段相对 COFF 头偏移大小含义Machine02 字节0x14C 是 x860x8664 是 x64NumberOfSections22 字节节的数量TimeDateStamp44 字节编译时间戳SizeOfOptionalHeader162 字节可选头字节数Characteristics182 字节文件特征位特征位里常见的是 0x0002 表示可执行文件0x2000 表示 DLL0x0100 表示 32 位指令。解析时不要只看 Machine 判断位数还要结合这几位一起看因为 32 位和 64 位文件在后续可选头里的布局差异很大先分清架构再往下读才不会错位。2.4 可选头与节表入口点、ImageBase 和节的映射可选头虽然名字里带 optional但实际必然存在。它的第一个字段 Magic 决定文件的位数0x10B 对应 PE3232 位0x20B 对应 PE3264 位。关键字段包括入口点 RVAAddressOfEntryPoint、加载基址 ImageBase、以及两个对齐值 SectionAlignment 和 FileAlignment。节表紧跟在可选头之后每个节表项固定 40 字节描述文件里每一段数据在磁盘上的位置和加载到内存后的位置之间的映射关系。常用的节包括 .text 代码节、.data 数据节、.rdata 只读数据节、.rsrc 资源节加壳文件的节名往往是乱码或短字符组合看到不认识的连续短节名要多留个心眼。字段节表项内偏移大小含义Name08 字节节名VirtualSize84 字节内存中大小VirtualAddress124 字节内存中 RVASizeOfRawData164 字节磁盘上大小PointerToRawData204 字节磁盘偏移这些偏移关系在下一章的代码里会直接落成 struct.unpack。现在只需要记住一句话RVA 和文件偏移是两套坐标系中间必须通过节表换算直接拿 RVA 当文件偏移去读读到的内容基本是错的。3. 手写一个最小 PE 解析器200 行读懂 exe 结构3.1 为什么自己写而不是直接抄工具现成的图形化 PE 工具用起来很容易但有两个问题一是输出格式固定二是分析过程像个黑匣子。自己写解析器最大的价值在于可控你能决定输出哪些字段、用什么格式输出还能把它接进批量脚本里。常见做法是先用 Python 写出完整解析逻辑验证偏移都对再决定要不要翻译成 C 或 Go。Python 的 struct 模块处理二进制非常顺手写一个小工具只需要几十行。下面这段直接可以跑核心路径是定位 PE 头、解析可选头、遍历节表、把入口点换算成文件偏移并定位到节。3.2 最小解析脚本从 DOS 头到节表import struct import sys def rva_to_offset(rva, sections): 把内存RVA换算成文件偏移先找到所属节再按节内偏移映射 for sec in sections: if sec[va] rva sec[va] sec[raw_size]: return rva - sec[va] sec[raw_ptr] return None def parse_pe(path): with open(path, rb) as f: data f.read() # 1. DOS头校验MZ标志是PE文件的最基本门槛 if data[:2] ! bMZ: raise ValueError(缺少 MZ 标志不是有效 PE 文件) # 2. e_lfanew 在 0x3C 处4字节小端整数 pe_off struct.unpack_from(I, data, 0x3C)[0] if data[pe_off:pe_off 4] ! bPE\x00\x00: raise ValueError(PE 签名校验失败) # 3. COFF文件头20字节位于PE签名之后 coff pe_off 4 machine, nsec, _, _, _, opt_size, chars struct.unpack_from(HHIIIHH, data, coff) # 4. 可选头先读Magic区分32位/64位 opt coff 20 magic, struct.unpack_from(H, data, opt) if magic 0x10B: bits 32 ep, struct.unpack_from(I, data, opt 16) image_base, struct.unpack_from(I, data, opt 28) elif magic 0x20B: bits 64 ep, struct.unpack_from(I, data, opt 16) image_base, struct.unpack_from(Q, data, opt 24) else: raise ValueError(未知可选头 Magic: 0x%X % magic) # 5. 节表从可选头之后开始每个节表项40字节 sec_off opt opt_size sections [] for i in range(nsec): base sec_off i * 40 name data[base:base 8].rstrip(b\x00).decode(ascii, replace) vsz, va, raw_size, raw_ptr struct.unpack_from(IIII, data, base 8) sections.append({ name: name, vsz: vsz, va: va, raw_size: raw_size, raw_ptr: raw_ptr }) # 6. 把入口点RVA换算成文件偏移并看它落在哪个节 ep_off rva_to_offset(ep, sections) ep_section next( (s[name] for s in sections if s[va] ep s[va] s[raw_size]), 未知 ) print(机器类型: 0x%X (%d位) % (machine, bits)) print(特征值: 0x%04X % chars) print(入口点 RVA: 0x%08X, 文件偏移: %s % (ep, hex(ep_off) if ep_off else N/A)) print(入口点所在节: %s % ep_section) print(ImageBase: 0x%X % image_base) print(%-8s %12s %12s %12s %12s % (节名, RVA, VirtualSize, RawOffset, RawSize)) for s in sections: print(%-8s 0x%08X %12d %12d %12d % ( s[name], s[va], s[vsz], s[raw_ptr], s[raw_size])) if __name__ __main__: parse_pe(sys.argv[1])逻辑说明整个脚本从文件头往后推进MZ 校验过了才读 e_lfanewPE 签名过了才读 COFF 头每一步都在上一步确认的数据范围内操作。入口点换算成文件偏移这一步很多人会漏掉只打印 RVA 的话你没法直接在文件里定位到入口指令所以这里做了个 rva_to_offset 函数复用到后面的导入表解析。参数说明COFF 头用HHIIIHH解析H 是 2 字节无符号整数I 是 4 字节无符号整数对应字段顺序依次是 Machine、NumberOfSections、TimeDateStamp、PointerToSymbolTable、NumberOfSymbols、SizeOfOptionalHeader、Characteristics。64 位的 ImageBase 是 8 字节所以从 opt24 开始读而 32 位是 4 字节在 opt28 位置这个偏移差异在避坑章还会专门讲。运行输出像这样$ python pe_reader.py sample.exe 机器类型: 0x14C (32位) 特征值: 0x0102 入口点 RVA: 0x00011020, 文件偏移: 0x00010220 入口点所在节: .text ImageBase: 0x00400000 节名 RVA VirtualSize RawOffset RawSize .text 0x00001000 5120 1024 4096 .rdata 0x00003000 2048 8192 20483.3 运行与字段参数说明Machine 字段对照不用死记常见就三种0x14C 是 x860x8664 是 x640xAA64 是 ARM64。看到其他值先别慌查表确认不要当成未知格式直接放弃有些冷门架构的驱动文件同样值得分析。Machine 值含义0x14Cx860x8664x640xAA64ARM64特征值里 0x2000 表示这个文件是 DLL0x0002 表示可执行。判断一个文件能不能被加载机器类型和特征值要一起看机器类型对但特征值指示 DLL那它就不是一个能独立运行的进程入口。3.4 边界与扩展把脚本接到别的工作流上这个脚本只能读磁盘文件不支持直接解析内存镜像。分析内存 dump 时结构会不一样映像加载后节按 VirtualSize 展开很多节在文件里只有部分数据解析逻辑要额外处理这是它和完整 PE 工具的主要差距。常见的扩展方向有把解析结果输出成 JSON 交给上游平台、接 YARA 规则做特征匹配、补上导入表和导出表解析。输出 JSON 其实就三行在 parse_pe 尾部收集一个字典然后 json.dump 到文件批量流程里最需要这种结构化输出后续排序、去重、生成报表都方便。4. 用批处理把 PE TOOLS 变成筛查工具节名、熵值与可疑特征4.1 图形工具初查三个必看的字段自己写了脚本日常初查我还是会先打开图形化 PE 工具因为它信息密度高树形结构一眼能看到全貌。打开样本后按固定顺序看三处Machine、入口点、节表。Machine 判断架构入口点看程序从哪开始执行节表看代码区怎么切分。这三处没问题再去看导入导出表和资源目录。看节表时重点扫两个字段节名和节的属性。正常编译器的节名规律性很强.text、.rdata、.data、.rsrc 来回就那几个。如果看到一个叫不出名字的短节且它的 VirtualSize 和 SizeOfRawData 差得很大就要想想为什么磁盘上和内存里的大小差这么多。4.2 加壳样本的三个信号信号一入口点不在 .text 节。正常编译的程序入口点通常落在代码节里壳的入口点往往指向一个高熵节节名是特征明显的短名字或者干脆是空白节。信号二导入表异常稀疏。被加壳后导入表往往只剩加载器初始化需要的少数 API正常程序一般会导入 kernel32、user32 等多个系统库的十几到几十个函数壳会把大部分导入信息藏到解压后的代码里。信号三节内熵值接近随机。压缩或加密后的字节分布趋近均匀熵值通常大于 7.5而正常代码节的熵值一般在 5.0 到 6.8 之间。这三个信号单独看都不足以定罪但两个以上同时命中样本就值得进沙箱跑一轮。指标正常区间可疑区间入口点所在节.text随机短节名或空白节熵值前 64KB4.0~6.8大于 7.5导入 DLL 数量通常大于等于 8常常小于等于 34.3 批量遍历对目录算熵值并标可疑import os import math from collections import Counter def shannon_entropy(data: bytes) - float: 计算一段字节的香农熵值越接近8说明越接近随机分布 if not data: return 0.0 cnt Counter(data) total len(data) return -sum((c / total) * math.log2(c / total) for c in cnt.values()) def scan_dir(root): issues [] for dirpath, _, files in os.walk(root): for fn in files: path os.path.join(dirpath, fn) try: with open(path, rb) as f: head f.read(2) if head ! bMZ: continue f.seek(0) blob f.read() except OSError: continue # 只取前64KB算熵覆盖了DOS头、PE头和节表也够判断代码区特征 ent shannon_entropy(blob[:65536]) if ent 7.5: issues.append((path, round(ent, 3), 高熵疑似压缩或加密)) return issues if __name__ __main__: for path, ent, reason in scan_dir(待分析目录): print(path, ent, reason)逻辑说明先读 2 字节判断是不是 MZ不是 PE 文件直接跳过避免把文本文件也算进来。只取前 64KB 算熵是为了省内存和 I/OPE 文件的头部、节表和数据目录都集中在前 64KB计算这一段足够反映关键区域的分布特征。算全文件反而会让大量零字节把熵值拉低掩盖真实信号。参数说明熵值阈值 7.5 不是拍脑袋定的正常代码节里指令重复率高字节分布不均匀压缩壳处理过的节字节分布均匀熵值很难低于 7.5。实际使用中你可能会遇到自解压安装包和资源型样本它们的前 64KB 熵值也会偏高属于需要接受的一类误报。把扫描结果输出成 CSV 也是常见的做法白天挂机跑目录晚上看新增清单比手动一个个拖进工具里高效得多。加壳检测不能只靠熵值。我一般会在脚本里再叠两个条件入口点不在 .text、导入表 DLL 数量小于等于 3。三个条件同时命中再标 high_risk误报率会低很多。单一指标筛出来的文件大多虚惊一场筛选逻辑宁可保守一点。5. PE 解析避坑RVA 换算、64 位偏移与头部字段被改解析 PE 的翻车点高度集中写过一段时间你会发现难点不在读代码而在于你默认字段就是它显示的那个意思。下面五条是我实际踩过的坑每条都按现象、原因、解决来写照着排查能省不少时间。5.1 RVA 与文件偏移换算解析器最常翻车的点现象从数据目录拿到导入表 RVA直接在文件里按这个偏移读取读出来的 DLL 名字符串全是乱码。原因RVA 描述的是文件被加载到内存后的相对地址文件在磁盘上按 FileAlignment 对齐内存按 SectionAlignment 对齐两套坐标系之间不是线性关系。直接把内存地址当文件偏移用读取位置落在节的中间甚至节之间的对齐空隙里解析结果自然全错。解决必须用节表换算先找到 RVA 落在哪个节的区间再用 offset RVA - VirtualAddress PointerToRawData 算出文件偏移。这个逻辑在第三、四章代码里都有复制一份放到工具里def rva_to_offset(rva, sections): for sec in sections: if sec[va] rva sec[va] sec[raw_size]: return rva - sec[va] sec[raw_ptr] return None补充一点如果函数返回 None说明 RVA 指向的节没有对应的磁盘数据常见于未初始化的节也可能说明节表被修改过样本本身就可疑。5.2 PE32 与 PE32 的可选头偏移不同现象同一份解析代码解析 32 位 exe 一切正常换成 64 位 dll 后 ImageBase 读出来是 0地址全乱。原因PE32 的 ImageBase 从 4 字节扩展成 8 字节导致可选头后半段字段整体向后挪。继续套 32 位布局去读 64 位文件从 ImageBase 开始后面的每个字段都是错位的。解决先读 Magic 区分位数再按位数选择字段偏移。这里把常用字段的偏移差异列出来写解析器时对照着用字段PE320x10BPE320x20BAddressOfEntryPoint1616ImageBase284 字节248 字节SizeOfImage4856SizeOfHeaders5260NumberOfRvaAndSizes88108数据目录起始92112注意 AddressOfEntryPoint 在两个版本里偏移相同所以很多人只读入口点不会发现有问题一旦读到 ImageBase 后面的字段才暴露排查时要往回看是不是位数分支没写对。5.3 VirtualSize 与 SizeOfRawData 别混用现象读取节内容时按 VirtualSize 去读后半段全是零或者直接抛越界错误。原因VirtualSize 是节加载到内存后的大小SizeOfRawData 是磁盘上按对齐值取整后的大小两个值可以不一样。磁盘上没有的数据内存里可能是零填充的拿 VirtualSize 去读文件必然越界或者读出一堆空洞。解决读文件内容用 PointerToRawData 加 SizeOfRawData分析内存布局用 VirtualAddress 加 VirtualSize。解析器里把这两个字段分开打印、分开使用不要混在同一份逻辑里。节表项 40 字节里前 8 字节是节名后面才是大小字段用 struct.unpack_from 定位时要算准偏移。5.4 SizeOfOptionalHeader 被改成超大值节表整体偏移现象解析加壳样本时机型、入口点看着都正常但节表第一个节名是乱码或者直接 struct.error。原因壳为了藏代码会把 COFF 头里的 SizeOfOptionalHeader 人为改大让节表起始位置整体后移。加载器按自己的规则读文件不一定受影响但解析器如果百分百相信这个字段从错位的位置开始读节表读出来的节名就全是乱码。解决不要盲信 SizeOfOptionalHeader。读到节表位置后先验证第一项的 Name 字段是不是可打印 ASCII如果不是用默认值 0xE032 位或 0xF064 位回退重读。再不行就枚举附近偏移找一段连续 40 字节且前 8 字节是可打印字符的可疑起点和 NumberOfSections 交叉验证。这个回退逻辑在批量扫描时尤其重要一个异常文件不应该拖垮整个任务。5.5 文件截断与 struct.error防御式解析现象批量扫描时遇到一个文件开头 MZ 正常读到节表时报 struct.error脚本当场退出后面文件全部停摆。原因文件没下载完整或被截断data 长度不够也可能 NumberOfSections 被改成异常大的值遍历越界。两种情况都表现为 struct.unpack 时数据不足。解决解析前先判断关键位置的长度读每一个结构前确认 len(data) 足够。批量脚本里遇到异常打印路径后 continue不要中断整个任务。防御式解析在安全分析场景里不是可选而是必须你扫描的目录里总会混入半截文件、空文件、被改坏的样本解析器要能扛住这些噪声if len(data) pe_off 24: raise ValueError(文件过短可能是截断文件)6. 进阶导入表解析与熵值联判把初筛做出确定性6.1 打印 DLL 名导入表的最小解析导入表在数据目录第一项拿到它的 RVA 后需要先换算成文件偏移然后遍历导入描述符。每个描述符占 20 字节DLL 名字符串的 RVA 在第 12 字节处def parse_import_names(data, sections, import_rva): off rva_to_offset(import_rva, sections) while off and len(data) off 20: name_rva struct.unpack_from(I, data, off 12)[0] if name_rva 0: break name_off rva_to_offset(name_rva, sections) end data.find(b\x00, name_off) print(data[name_off:end].decode(ascii, replace)) off 20注意这里只打印了 DLL 名完整解析导入函数还要再走一层 Hint/Name 表循着 OriginalFirstThunk 或 FirstThunk 指向的内存数组逐个读。生产环境里建议全部展开因为导入函数名往往比 DLL 名更能说明行为一个程序导入了 CreateRemoteThread 却没导入正常的进程管理 API这个组合本身就很可疑。6.2 熵值联判阈值怎么定单独看熵值会误报自解压程序和资源型样本的前 64KB 熵也不低。我一般用三个条件叠加入口点不在 .text、熵值大于 7.5、导入 DLL 数量小于等于 3。同时命中再标记 high_risk 并送进沙箱。阈值的设定没有普适标准不同编译器、不同语言生成的代码熵值有差异建议你自己收集一批正常编译的 exe 和一批已知加壳样本各算一遍熵值后再划阈值比照搬经验值可靠得多。6.3 我的标准动作与一次翻车教训有一回分析一个样本节名全是常见名导入表 DLL 数量也正常当时就放松了警惕。后来发现入口点居然落在 .rdata 里当时想当然觉得是编译器抽风直接跳过了沙箱验证差点误判。从那以后我给自己定了一条死规矩入口点不在 .text 的样本不管其它指标多正常都必须单独跑一轮沙箱这是血泪换来的教训。日常初筛我坚持“三查”入口点节归属、导入表密度、前 64KB 熵值三项齐全才敢说这个文件问题不大。做分析这行永远不要相信单一指标多个信号交叉验证比什么都重要。希望帮到你。本文还有配套的精品资源点击获取