简介:PE TOOLS 是一套面向软件开发者、逆向工程学习者与系统管理员的 Windows PE 格式分析工具合集,用于查看、解析和修改可执行文件与 DLL 的内部结构。压缩包共 83 个文件,约 301KB,以 dll 动态库、exe 可执行程序为主,辅以 dsp/dsw 工程文件、cpp/h 源码、lib 静态库、asm 汇编、bat 批处理及 txt 说明文档,覆盖主程序、插件与 SDK 示例等模块。其中 PETools.exe 为查看与分析 PE 文件的主程序,HEdit32、RebPE32、PESniffer、NDump 等库分别承担编辑、增强、嗅探与内存转储功能,PSAPI.DLL 与 Procs32 系列则用于进程信息获取,SDK 目录还附带 Include、Examples 等开发示例。资源围绕 DOS 头、PE 签名、COFF 头、可选头、节区表、导入导出表、资源表、重定位表、调试信息与证书等核心结构展开,可帮助读者直观理解 PE 文件的组织方式,并借助配套工具完成结构查看、代码调试与局部修改。目前已有 808 人学习下载,适合作为研究 PE 格式与逆向工程的实用参考。
1. PE TOOLS 到底在解决什么问题:从一个 DLL 加载失败说起
某天下午,同事甩过来一个报错截图:某个自研的插件 DLL 在目标机器上死活加载不起来,报错只有一句干巴巴的0xc000007b。开发机上跑得好好的,换台机器就翻车。这种时候,日志没用,调试器还没 attach 上去进程就退了,唯一能指望的就是把那个 DLL 拖进 PE 工具里,看看它的头结构到底长什么样——是位数不对,还是依赖的某个导入表项在目标机器上找不到。
PE TOOLS 这类工具,处理的就是 Windows 平台上的 PE 文件格式(Portable Executable)。exe、dll、sys、ocx,甚至部分 cpl 和 scr,底层都是同一套 PE 结构。它要解决的问题很具体:当你手上只有一个二进制文件,没有源码、没有符号、没有文档,你需要知道它依赖哪些 DLL、导出了哪些函数、节区怎么分布、是不是被加壳了、编译目标是什么架构。做逆向、做安全审计、做兼容性排查、做恶意样本初筛的人,几乎每天都要和它打交道。
这篇文章不打算把 PE 格式的每个字段都背一遍,而是按「先看懂结构 → 再动手解析 → 再排错 → 最后落到自动化」的路径走一遍。适合两类人:一类是刚接触二进制分析、想搞清楚 PE 工具到底在看什么的新手;另一类是已经会用工具、但想自己写解析脚本把流程串起来的熟手。中间会给可直接抄的 Python 代码,也会给参数表和踩坑记录。
2. PE 文件结构拆解:工具界面里那些字段分别对应磁盘上的哪个字节
要会用 PE 工具,先得知道工具展示的东西是从文件哪个偏移读出来的。否则界面上一堆十六进制数字,你只能看个热闹。PE 文件在磁盘上的布局是「头 + 节表 + 节数据」的线性结构,工具做的事情本质上就是按固定偏移去读、按结构体去解释。
2.1 DOS 头、NT 头与节表:三个必须先定位的锚点
文件最开头是 IMAGE_DOS_HEADER,固定 64 字节。它里面大部分字段是给 DOS 时代用的,现在唯一有价值的是偏移0x3C处的 4 字节e_lfanew,它指向真正的 NT 头。很多人手写解析时直接假设 NT 头在0x80,在多数编译器产物上碰巧对,但遇到某些加壳或手工构造的样本就会读飞,这是血泪经验。
NT 头由三部分组成:4 字节签名PE\0\0、20 字节的 IMAGE_FILE_HEADER、以及可选头 IMAGE_OPTIONAL_HEADER。文件头里有几个关键字段:Machine表示目标架构(0x14c 是 x86,0x8664 是 x64,0x1c0 是 ARM),NumberOfSections是节区数量,Characteristics里的 0x2000 位表示这是个 DLL 而不是 exe。
可选头虽然叫「可选」,但对可执行文件是必须的。它里面Magic字段区分 32 位(0x10b)和 64 位(0x20b),AddressOfEntryPoint是入口点的 RVA,ImageBase是首选加载基址,Subsystem区分控制台程序和 GUI 程序。节表紧跟在可选头之后,每个节区 40 字节,描述节名、虚拟地址、虚拟大小、磁盘偏移、磁盘大小和节区属性。
下面这段代码用纯 Python 把这三个锚点读出来,不依赖任何第三方库,方便你理解偏移关系:
import struct def parse_pe_headers(path): with open(path, "rb") as f: data = f.read() # DOS 头固定 64 字节,e_lfanew 在偏移 0x3C if data[:2] != b"MZ": raise ValueError("不是有效的 PE 文件:缺少 MZ 签名") e_lfanew = struct.unpack_from("<I", data, 0x3C)[0] # NT 头签名 if data[e_lfanew:e_lfanew + 4] != b"PE\x00\x00": raise ValueError("NT 头签名不匹配") nt_offset = e_lfanew + 4 # IMAGE_FILE_HEADER:Machine(2) Sections(2) TimeStamp(4) ... machine, num_sections = struct.unpack_from("<HH", data, nt_offset) opt_size = struct.unpack_from("<H", data, nt_offset + 16)[0] # 可选头起始位置 opt_offset = nt_offset + 20 magic = struct.unpack_from("<H", data, opt_offset)[0] is_64 = (magic == 0x20B) # 入口点 RVA 在可选头偏移 16 处,ImageBase 在 28(32位)/24(64位) entry_rva = struct.unpack_from("<I", data, opt_offset + 16)[0] if is_64: image_base = struct.unpack_from("<Q", data, opt_offset + 24)[0] else: image_base = struct.unpack_from("<I", data, opt_offset + 28)[0] # 节表紧跟在可选头之后 sec_offset = opt_offset + opt_size sections = [] for i in range(num_sections): base = sec_offset + i * 40 name = data[base:base + 8].rstrip(b"\x00").decode("ascii", "ignore") v_size, v_addr, r_size, r_off = struct.unpack_from("<IIII", data, base + 8) sections.append({ "name": name, "v_size": v_size, "v_addr": v_addr, "raw_size": r_size, "raw_offset": r_off }) return { "machine": hex(machine), "is_64": is_64, "entry_rva": hex(entry_rva), "image_base": hex(image_base), "sections": sections } info = parse_pe_headers("sample.dll") print(info["machine"], info["is_64"], info["entry_rva"]) for s in info["sections"]: print(s["name"], hex(s["v_addr"]), hex(s["raw_offset"]))逻辑上分四步:先校验 MZ 签名,再通过e_lfanew定位 NT 头,然后根据 Magic 判断位数并读取入口点和基址,最后按 40 字节步长遍历节表。参数上要注意struct.unpack_from的字节序统一用<(小端),PE 格式在磁盘上就是小端存储。opt_size不能写死,32 位和 64 位可选头长度不同,必须从文件头里读。
2.2 数据目录与导入导出表:定位依赖和导出函数的真实入口
可选头后半段是数据目录(Data Directory),一共 16 项,每项 8 字节(RVA + Size)。对排查问题最有用的三项是:索引 0 的导出表、索引 1 的导入表、索引 2 的资源表。导入表告诉你这个模块依赖哪些外部 DLL 和函数,导出表告诉你它对外提供什么。
导入表的结构是 IMAGE_IMPORT_DESCRIPTOR 数组,每项 20 字节,以全零项结束。每项里Name字段是一个 RVA,指向依赖的 DLL 名字符串;FirstThunk指向导入地址表(IAT)。要拿到具体函数名,需要顺着 OriginalFirstThunk 指向的 IMAGE_THUNK_DATA 数组走,每个表项如果最高位没置位,就是一个指向 IMAGE_IMPORT_BY_NAME 的 RVA,里面存着函数名。
这里有个关键点:RVA 到文件偏移的转换不是简单相加。需要用节表做映射——找到 RVA 落在哪个节的[v_addr, v_addr + v_size)区间内,然后文件偏移 = RVA - v_addr + raw_offset。这个转换函数是后面所有解析的基础,写错一次后面全错:
def rva_to_offset(sections, rva): for s in sections: if s["v_addr"] <= rva < s["v_addr"] + max(s["v_size"], s["raw_size"]): return rva - s["v_addr"] + s["raw_offset"] return None # 落在头部区域或无效 RVA def read_cstring(data, offset): end = data.index(b"\x00", offset) return data[offset:end].decode("ascii", "ignore") def parse_imports(data, sections, import_rva): imports = {} off = rva_to_offset(sections, import_rva) while True: desc = data[off:off + 20] if len(desc) < 20 or desc == b"\x00" * 20: break name_rva, _, _, first_thunk = struct.unpack_from("<IIII", desc, 0) if name_rva == 0: break dll_name = read_cstring(data, rva_to_offset(sections, name_rva)) imports[dll_name] = [] off += 20 return imports参数说明:import_rva从数据目录索引 1 取。rva_to_offset里用max(v_size, raw_size)是为了兼容某些节区虚拟大小和磁盘大小不一致的情况,这是常见坑。实际生产脚本里还要处理 OriginalFirstThunk 为 0 时回退到 FirstThunk 的情况,以及序号导入(最高位置位)的分支。
2.3 用现成工具交叉验证:别只信自己写的解析器
自己写解析器是为了理解结构,但排查真实问题时,还是得靠成熟工具交叉验证。常见组合是:用 PE 查看类工具看概览和节区,用依赖查看类工具看导入导出,用十六进制编辑器看原始字节。三者对不上时,优先怀疑自己的 RVA 转换,而不是怀疑工具。
一个实用习惯是:先用工具打开目标文件,记下入口点 RVA、节区数量和导入 DLL 列表,再跑自己的脚本,逐项对比。如果节区数量对不上,多半是NumberOfSections读错偏移;如果导入列表为空,多半是数据目录索引取错或 RVA 转换失败。这种交叉验证能省掉大量瞎猜时间。
3. 从零写一个 PE 解析脚本:把导入表、节区熵和编译时间一次读全
光会读头结构还不够,实际排查时你更关心的是「这个文件可不可疑」「依赖全不全」「是不是被加壳了」。这一章把解析脚本补全,加上节区熵计算和编译时间戳解析,让它能直接用于初筛。
3.1 节区熵计算:判断是否加壳的第一手依据
加壳后的 PE 文件,代码和数据通常被压缩或加密,节区的熵值会明显偏高。正常编译产物的代码节熵一般在 6.0 以下,数据节更低;而压缩壳处理过的节区熵经常接近 7.9。计算方法是把节区原始数据按字节统计频率,套用香农熵公式。
import math from collections import Counter def section_entropy(data, offset, size): if size == 0: return 0.0 chunk = data[offset:offset + size] counter = Counter(chunk) length = len(chunk) entropy = 0.0 for count in counter.values(): p = count / length entropy -= p * math.log2(p) return round(entropy, 4) # 对每个节区计算熵 for s in info["sections"]: ent = section_entropy(data, s["raw_offset"], s["raw_size"]) print(f'{s["name"]:8s} entropy={ent}')逻辑说明:Counter统计每个字节值出现次数,p是频率,熵是-Σ p·log2(p)。参数上size用磁盘大小raw_size而不是虚拟大小,因为我们要分析的是磁盘上实际存储的字节。判断阈值我一般这样用:熵大于 7.2 且节区名不是标准名(如.text、.data、.rdata),就要警惕;如果多个节区熵都接近 7.9,基本可以判定加了压缩壳。
3.2 编译时间戳与调试目录:判断文件来源的辅助线索
IMAGE_FILE_HEADER 里的TimeDateStamp是 Unix 时间戳,表示链接器生成文件的时间。虽然可以被篡改,但正常编译产物里它是有意义的。用datetime.utcfromtimestamp转一下就能看。如果时间戳是 0 或者明显不合理(比如 1970 年、未来时间),要么是手工构造的样本,要么是用了可复现构建(reproducible build)抹掉了时间。
调试目录在数据目录索引 6,指向 IMAGE_DEBUG_DIRECTORY 数组。里面 Type 为 2 的是 CodeView 记录,通常包含 PDB 文件路径。这个路径能透露编译环境信息,比如是本地路径还是 CI 路径。排查兼容性问题时,PDB 路径有时能帮你确认这个 DLL 是不是从某个特定构建流水线出来的。
import datetime def parse_timestamp(ts): if ts == 0: return "未设置(可能被抹除)" try: return datetime.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S UTC") except (OSError, ValueError): return f"非法时间戳: {hex(ts)}" # 从文件头偏移 4 处读 TimeDateStamp ts = struct.unpack_from("<I", data, nt_offset + 4)[0] print("编译时间:", parse_timestamp(ts))参数说明:nt_offset + 4是 TimeDateStamp 在文件头里的位置(Machine 2 字节 + NumberOfSections 2 字节之后)。异常时间戳不要直接下结论说文件有问题,先结合其他特征看,因为可复现构建确实会把它设成固定值。
3.3 把解析结果落成结构化报告:方便批量比对
单文件看完了,下一步是批量。把前面所有解析结果组织成一个字典,输出成 JSON,就能对一批文件做横向对比。比如找出所有导入某个特定 DLL 的模块,或者筛出所有熵值超标的节区。
import json def build_report(path): with open(path, "rb") as f: data = f.read() info = parse_pe_headers(path) report = { "file": path, "machine": info["machine"], "is_64": info["is_64"], "entry_rva": info["entry_rva"], "image_base": info["image_base"], "sections": [] } for s in info["sections"]: report["sections"].append({ "name": s["name"], "v_addr": hex(s["v_addr"]), "raw_offset": hex(s["raw_offset"]), "entropy": section_entropy(data, s["raw_offset"], s["raw_size"]) }) return report print(json.dumps(build_report("sample.dll"), indent=2, ensure_ascii=False))逻辑说明:报告里保留十六进制字符串是为了可读性,熵值保留浮点。批量跑的时候,把build_report套一个目录遍历,结果写进一个 JSON 数组,后面用脚本做筛选。参数上indent=2和ensure_ascii=False是为了输出可读的中文和缩进,实际入库时可以去掉缩进省空间。
4. PE 工具使用中的避坑与排查:那些让解析结果对不上的原因
这一章集中讲踩过的坑。PE 解析看起来是纯结构读取,但真实文件里各种边界情况很多,下面五条是最常见的。
4.1 现象:节区数量读出来是 0 或异常大
原因通常是 NT 头定位错了。有些文件在 MZ 和 PE 签名之间塞了额外数据,或者e_lfanew被故意改成一个很大的值。如果直接假设 NT 头在固定偏移,就会读到垃圾数据。
解决:永远从0x3C读e_lfanew,并且校验e_lfanew是否在文件大小范围内、PE\0\0签名是否匹配。两个校验都过了再往下走。如果签名不匹配,说明这个文件可能不是标准 PE,或者被特殊处理过。
4.2 现象:导入表解析出来是空的,但工具里明明有导入
原因多半是 RVA 到文件偏移的转换失败。常见于 RVA 落在头部区域(比如某些节区起始地址之前),或者节区的v_size和raw_size差异很大导致区间判断出错。
解决:在rva_to_offset里加日志,把每个 RVA 和它匹配到的节区打出来。如果返回 None,检查是不是数据目录索引取错了——导入表是索引 1,不是索引 0。另外注意 64 位文件的数据目录项也是 8 字节,不要按 32 位去读。
4.3 现象:熵值算出来全是 0 或异常低
原因通常是节区原始数据读取范围错了。如果raw_offset或raw_size读错,可能读到全零区域,熵自然接近 0。还有一种情况是文件被截断,raw_offset + raw_size超出了实际文件长度。
解决:读取前先判断raw_offset + raw_size <= len(data),超出就截断到文件末尾并记录警告。熵值异常低时,先打印节区前 16 字节看看是不是全零,确认数据范围对不对。
4.4 现象:同一个文件在不同工具里显示的入口点不一样
原因是有的工具显示 RVA,有的显示 VA(虚拟地址),还有的显示文件偏移。RVA 加上 ImageBase 才是 VA,RVA 经过节表映射才是文件偏移。三个值完全不同,混着看就会以为工具出错。
解决:统一口径。排查时我一般固定看 RVA,因为它是文件里直接存的。需要和调试器里的地址对比时,再手动加 ImageBase。在报告里明确标注每个地址是 RVA 还是 VA,避免自己后面看混。
4.5 现象:解析脚本在某个文件上直接抛异常退出
原因是没做边界检查。真实样本里字段值可能任意,比如NumberOfSections是 0xFFFF,按这个数量去遍历节表就会越界。或者字符串读取时找不到\x00终止符,index抛 ValueError。
解决:所有struct.unpack_from前先检查偏移加长度是否在数据范围内;所有字符串读取用带默认值的封装,找不到终止符就返回已读部分或空串。解析器要能对畸形文件返回部分结果加错误信息,而不是直接崩掉,否则批量跑的时候一个坏文件会中断整个流程。
5. 把 PE 解析接进自动化流程:批量筛选与结果验证的实用技巧
单文件解析只是起点,真正省时间的是把它接进批量流程。我一般的做法是:目录遍历 → 逐个解析 → 输出 JSON → 用筛选条件挑出可疑文件 → 人工复核。这套流程在样本初筛和兼容性排查里都能用。
5.1 批量解析与筛选条件设计
批量脚本的核心是把build_report套进os.walk,然后对结果做条件筛选。常用的筛选维度有三个:架构不匹配(比如目标环境是 x64 但文件是 x86)、节区熵超标(疑似加壳)、导入表里出现非常规 DLL。
import os def scan_directory(root): results = [] for dirpath, _, filenames in os.walk(root): for fn in filenames: if not fn.lower().endswith((".exe", ".dll", ".sys")): continue full = os.path.join(dirpath, fn) try: results.append(build_report(full)) except Exception as e: results.append({"file": full, "error": str(e)}) return results def filter_suspicious(reports, max_entropy=7.2): hits = [] for r in reports: if "error" in r: continue for s in r.get("sections", []): if s["entropy"] > max_entropy: hits.append((r["file"], s["name"], s["entropy"])) return hits reports = scan_directory("./samples") for f, sec, ent in filter_suspicious(reports): print(f"{f} 节区 {sec} 熵 {ent}")逻辑说明:scan_directory只处理常见 PE 扩展名,遇到异常文件记录错误但不中断。filter_suspicious的阈值 7.2 是经验值,可以按需调整——调低会多报,调高会漏报。参数上max_entropy建议先用 7.2 跑一遍,看看命中数量,再决定是否收紧。
5.2 结果验证:用已知正常文件做基线
筛选出来的可疑文件不能直接下结论,得先验证筛选逻辑本身对不对。方法是拿一批已知正常的编译产物跑一遍,看会不会误报。如果正常文件的某个节区熵也超过 7.2,要么是阈值定低了,要么是这个节区确实存了压缩数据(比如嵌入了压缩资源)。
验证时重点看两类误报:一是资源节,里面可能存了压缩过的图片或数据,熵天然偏高;二是某些编译器生成的调试节,内容随机性也大。把节区名纳入判断条件能过滤掉大部分误报,比如只对.text和未知名称的节区做熵检查。
5.3 一个具体技巧:用导入表差异定位缺失依赖
回到第 1 章那个加载失败的场景。把开发机上的正常 DLL 和目标机器上失败的 DLL 分别解析导入表,做差集,就能快速定位是哪个依赖项在目标机器上缺失或版本不对。这个技巧比逐个dumpbin快得多,尤其是依赖几十个 DLL 的复杂模块。
def diff_imports(report_a, report_b): # report_a 为正常文件,report_b 为异常文件 imports_a = set(report_a.get("imports", {}).keys()) imports_b = set(report_b.get("imports", {}).keys()) only_in_a = imports_a - imports_b only_in_b = imports_b - imports_a return {"正常有异常无": sorted(only_in_a), "异常有正常无": sorted(only_in_b)}参数说明:两个报告需要先补全imports字段(把 2.2 节的解析结果塞进去)。差集结果里「正常有异常无」通常就是缺失的依赖,重点排查这些 DLL 在目标机器上是否存在、位数是否匹配。这个技巧我用了很多次,比对着报错猜快得多。
最后说个习惯:每次解析完一个不熟悉的文件,我都会把入口点 RVA、节区列表和导入 DLL 数量记在一张便签上,和工具显示的结果对一遍。对不上就先查自己的 RVA 转换,别急着怀疑文件有问题。这个习惯帮我省下了大量返工时间。希望帮到你。
本文还有配套的精品资源,点击获取