简介CFF_Explorer是一款面向Windows平台的PE文件格式分析与编辑工具主要服务于逆向工程师、安全研究人员和软件调试人员支持查看与修改文件头、节区、导入导出表、资源目录、数字签名等结构可应用于恶意样本分析、软件汉化、依赖定位等场景。资源包共收录16个文件包括主程序exe、PE平台签名xml、使用说明与脚本指南pdf、cff脚本实例以及dll和msi扩展组件整体压缩包约2.07MB内容紧凑。目前已有783人学习下载。包内提供了CFF Explorer主程序、SDK开发文档、UPX压缩工具和可直接运行的补丁生成、报告输出脚本既能让初学者按照文档系统学习PE解析也能帮助熟练用户快速完成文件头修改或资源编辑等日常调试工作称得上是一个轻量而实用的逆向工具资料包。1. CFF_Explorer 是什么给 PE 文件做一次结构化「解剖」拿到一个来路不明的 EXE第一件事绝对不是双击运行而是把它丢进解析器里做一次「体检」。导入表里躺着 VirtualAlloc、CreateRemoteThread、WinExec 的时候攻击者下一步要做什么基本已经写在明面上了。CFF_Explorer 这个项目要解决的就是把 Windows 可执行文件里层层叠叠的偏移、RVA、数据目录这些「黑匣子」折叠成资源管理器式的树形结构让每个字段都看得见、点得开。它适合三类人做恶意样本分析的、被链接错误和加载失败折磨的桌面软件工程师、以及刚开始接触逆向想搞清楚 EXE 内部结构的新手。这篇文章不聊宏大的框架只讲怎么从零把一个 PE 文件拆开并且避开我自己踩过的偏移、位数和对齐三个大坑。2. 动手前先看清地图DOS 头、NT 头、节表与数据目录2.1 为什么 PE 文件必须用「浏览器」而不是十六进制编辑器PE 文件不是一份平铺直叙的二进制数据。文件最前面是 DOS 头它存在的历史意义是让老系统认为这是个 DOS 程序但现代 Windows 只关心它尾部的一个字段e_lfanew一个 4 字节偏移指向真正的 NT 头。NT 头里是一份文件头加一份可选头可选头末尾又挂着一张数据目录表目录表每一项都指向某个表或目录的 RVA相对虚拟地址。这意味着你想找一个函数的导入记录必须顺着「文件头 → 可选头 → 数据目录 → 导入描述符 → 节表 → 文件偏移」一路换算过去。十六进制编辑器只能给你一堆十六进制数字层级关系全靠人脑硬记这在分析几十个样本时完全不可行。CFF_Explorer 这类工具的核心价值不是帮你「看字节」而是把这些间接寻址关系变成可直接展开的树。看一个 DLL 导出了哪些函数只需要展开导出目录看加载配置里有没有开 CFG控制流保护只需要点开 Load Config 目录。数据是同一份数据工具做的是把「偏移的迷宫」翻译成「带名字的路径」。所以动手写解析器之前必须先把这个地图装进脑子里后面每一个字段偏移从哪来、指向哪里都要能用手指点出来。为什么不直接用 dumpbindumpbin 是编译链自带的工具/imports 一眼能看到导入表但它的输出面向「人读」不是结构化数据而且对损坏和加壳样本经常直接拒绝工作。CFF_Explorer 走的是「自己掌握解析路径」的路线所有目录都是代码里的一个函数每个字段都能单独调用出错时能明确知道是哪一个环节。这也是为什么我建议即使有现成库第一步还是手写一次解析——不亲手走一遍偏移你永远不知道工具给出的异常信息到底错在哪。2.2 一张数据目录表看懂 PE 的所有功能区数据目录在可选头末尾最多 16 项每项 8 字节由「RVA Size」组成。下表是我每次做解析器都会先抄在笔记里的索引表索引号在代码里经常直接出现。索引目录名作用0ExportDLL 对外的导出函数表1Import从外部 DLL 导入的函数表2Resource图标、对话框、版本信息3Exception异常处理表64 位下常见4Security证书表通常位于文件尾部5Base Reloc基址重定位表6Debug调试信息目录7Architecture保留8GlobalPtr全局指针9TLS线程局部存储模板10Load Config加载配置CFG/SEH 在这里11Bound Import绑定导入表12IAT导入地址表13Delay Import延迟导入表14COM Descriptor.NET 元数据入口15Reserved保留解析顺序是固定的读 MZ 头 → 取 e_lfanew → 校验 PE 签名 → 读文件头 → 跳过可选头 → 到达节表。节表本身是一个固定长度数组每个节 40 字节记录了节的名字、虚拟地址、虚拟大小、文件中原始数据的位置和大小。打开一个 PE 的完整流程本质上就是一份纯粹的「偏移 长度」算术题没有任何魔法。但很多人就是在这份算术里翻车根子在于没有分清「内存中的样子」和「磁盘上的样子」是两回事这也是 RVA 与文件偏移两套坐标存在的根本原因。可选头里还有几个字段在 CFF_Explorer 的概览页上必须直接展示AddressOfEntryPoint 是程序入口点 RVAImageBase 是加载基址PE32 默认 0x400000PE32 默认 0x140000000但可以改SizeOfImage 是内存镜像总大小Subsystem 区分 GUI 和 CUI 程序DllCharacteristics 里包含 ASLR0x40、DEP0x100、CFG0x4000等保护标记。这些字段决定了一个样本能不能跑、怎么跑、是否被现代系统的保护机制约束概览页不突出它们工具就只是个「字段查看器」离「探索器」还差得远。3. 用 CFF_Explorer 的解析思路写最小 PE 头解析器3.1 从 MZ 到 PE 签名e_lfanew 是入口钥匙解析器的第一步永远是从文件头两字节 MZ 开始然后读偏移 0x3C 处的 e_lfanew 字段。这个字段指向的是「PE 签名」在文件中的绝对偏移不是相对 DOS 头起点的偏移新手经常在这里想当然地加错基址。下面的代码用 Python 的 struct 模块直接解析文件头不依赖任何第三方库适合先把原理跑通。import struct def parse_nt_header(file_path): with open(file_path, rb) as f: data f.read() if data[0:2] ! bMZ: raise ValueError(不是 PE 文件开头缺少 MZ 头) # 0x3C 处是 e_lfanew4 字节小端整数指向 NT 头起始位置 e_lfanew struct.unpack_from(I, data, 0x3C)[0] if data[e_lfanew:e_lfanew 4] ! bPE\x00\x00: raise ValueError(PE 签名不存在文件可能被改过头) # 文件头共 20 字节Machine/H, NumberOfSections/H, TimeDateStamp/I, # PointerToSymbolTable/I, NumberOfSymbols/I, SizeOfOptionalHeader/H, Characteristics/H machine, num_sections, _, _, _, opt_size, _ struct.unpack_from( HHIIIHH, data, e_lfanew 4 ) machine_name x64 if machine 0x8664 else (x86 if machine 0x014C else hex(machine)) print(fmachine{machine_name}, sections{num_sections}, optional_header_size{opt_size}) return data, e_lfanew, num_sections, opt_size这段代码的逻辑很直接前两字节校验 MZ0x3C 处取 NT 头偏移再校验四字节的 PE 签名。struct.unpack_from的第一个参数HHIIIHH表示全部按小端读取因为 x86/x64 体系下 PE 文件字段都是小端存储。其中Machine字段 0x8664 代表 AMD640x014C 代表 x86这一步决定了后面 thunk 的宽度是后续所有解析的分叉口。这里最容易犯的错是只判断 MZ 就继续往下读不去校验 PE 签名结果加壳或损坏样本会在后面给你一个完全看不懂的报错。3.2 节表与 RVA/FOA 换算整个工具的地基有了文件头和可选头大小就能算出节表的起始位置e_lfanew 4签名长度 20文件头长度 可选头大小。可选头的大小不固定32 位通常是 0xE0224 字节64 位通常是 0xF0240 字节但不要写死用文件头里的 SizeOfOptionalHeader 字段最稳妥。每个节表项 40 字节解析完就得到了每个节的内存布局和磁盘布局。def parse_sections(data, nt_offset, opt_size, num_sections): sec_offset nt_offset 4 20 opt_size sections [] for i in range(num_sections): off sec_offset i * 40 name data[off:off 8].rstrip(b\x00).decode(latin1) # 8 字节名字之后依次是VirtualSize/I, VirtualAddress/I, # SizeOfRawData/I, PointerToRawData/I, Characteristics/I vsize, vaddr, raw_size, raw_ptr struct.unpack_from(IIII, data, off 8) sections.append({ name: name, vaddr: vaddr, vsize: vsize, raw_ptr: raw_ptr, raw_size: raw_size }) return sections def rva_to_file_offset(rva, sections): for s in sections: # 内存中节的结束位置要取 VirtualSize 和 SizeOfRawData 的较大值 end s[vaddr] max(s[vsize], s[raw_size]) if s[vaddr] rva end: return s[raw_ptr] (rva - s[vaddr]) return rva # 落在头部区域的 RVA 通常可以直接当文件偏移用rva_to_file_offset 是 CFF_Explorer 里被调用次数最多的函数没有之一。它的作用是把运行时虚拟地址换算成查看文件时的字节偏移。VirtualAddress是节在内存中的起始地址PointerToRawData是节在文件中的起始位置两者之间的差值就是一个固定的换算常数。计算结束位置时必须用VirtualSize和SizeOfRawData的较大值因为有些节的文件对齐比内存对齐小文件里实际存放的数据可能比内存映射长也可能反过来。Characteristics字段决定了节的权限这个字段在做安全分析时比看名字更可靠。节权限的常见值要能一眼认出来0x60000020 表示代码节可读可执行0xE0000020 表示可执行可读可写常见于加壳程序的自解压段0xC0000040 表示可读可写数据节不能执行。遇到 EXE 的代码段同时可写基本可以断定它运行时会动态生成代码这是定位壳和注入逻辑的重要信号。加壳器会把 .text 改名为任意字符串但权限位很难全部伪装到位所以我在工具输出里总是把节权限单独打印一行。4. 把导入表导出表变成可读清单CFF_Explorer 的核心玩法4.1 从数据目录 1 号入口找到导入表数据目录第一项是导出表第二项才是导入表。导入表的 RVA 指向一个 IMAGE_IMPORT_DESCRIPTOR 数组每个描述符固定 20 字节数组以全零结构体结尾中间不允许出现空洞。描述符里最重要的三个字段是 OriginalFirstThunk、Name 和 FirstThunkName 是指向 DLL 名字符串的 RVAOriginalFirstThunk 指向 INT导入名表FirstThunk 指向 IAT导入地址表。解析时优先用 OriginalFirstThunk如果它为 0再用 FirstThunk 兜底这是处理某些手工构造样本时保命的习惯。def parse_imports(data, sections, nt_offset): opt_offset nt_offset 4 20 magic struct.unpack_from(H, data, opt_offset)[0] is_64 (magic 0x20B) # PE32 为 0x20BPE32 为 0x10B thunk_size 8 if is_64 else 4 ordinal_flag 0x8000000000000000 if is_64 else 0x80000000 # PE32 数据目录在可选头内偏移 96PE32 在偏移 112索引 1 是导入表 dir_base opt_offset (112 if is_64 else 96) import_rva, import_size struct.unpack_from(II, data, dir_base 1 * 8) if import_rva 0: return [] desc_off rva_to_file_offset(import_rva, sections) imports [] for _ in range(4096): # 保险丝防止描述符数组没有全零终止 orig_thunk, _, _, name_rva, first_thunk struct.unpack_from( IIIII, data, desc_off ) if orig_thunk 0 and name_rva 0 and first_thunk 0: break dll_name _read_cstring(data, rva_to_file_offset(name_rva, sections)) thunk_rva orig_thunk if orig_thunk ! 0 else first_thunk thunk_off rva_to_file_offset(thunk_rva, sections) funcs [] while True: thunk_data struct.unpack_from(Q if is_64 else I, data, thunk_off)[0] if thunk_data 0: break if thunk_data ordinal_flag: # 高位为 1说明按序号导入 funcs.append(fordinal_{thunk_data 0xFFFF}) else: # 低位指向 IMAGE_IMPORT_BY_NAME hint_off rva_to_file_offset(thunk_data, sections) funcs.append(_read_cstring(data, hint_off 2)) thunk_off thunk_size imports.append({dll: dll_name, funcs: funcs}) desc_off 20 return imports代码里的_read_cstring是通用小函数从指定偏移读到第一个 \x00 前的字节并解码。逻辑说明thunk 数组每一项要么是序号最高位为 1要么是一个指向 Hint/Name 结构的 RVA解析函数名时要先跳过 2 字节的 Hint。参数上最需要注意的是thunk_size32 位程序是 4 字节64 位程序是 8 字节写死其中一个会让整个导入表错位。ordinal_flag随位数变化也同理x64 下的高位是第 63 位和 x86 完全不同。提示解析时始终从数据目录 1 号入口走不要拿 12 号 IAT 目录当导入表用。IAT 在加载后会被系统改写成真实地址静态文件里的 IAT 内容不一定完整。4.2 导出表解析DLL 对外接口的读取方式导出表位于数据目录索引 0结构比导入表简单一个 IMAGE_EXPORT_DIRECTORY 头加三张平行数组。头里 Base 字段是序号的起始值NumberOfFunctions 是函数总数NumberOfNames 是具名函数数AddressOfFunctions、AddressOfNames、AddressOfNameOrdinals 三张表分别记录函数 RVA、名字字符串 RVA、名称到函数序号的映射。按名字遍历时需要先用 AddressOfNameOrdinals 里对应的序号索引去 AddressOfFunctions 里取真正的函数 RVA。def parse_exports(data, sections, export_rva): if export_rva 0: return [] off rva_to_file_offset(export_rva, sections) base, nfunc, nnames, funcs_addr, names_addr, ords_addr struct.unpack_from( IIIIII, data, off 16 ) names_off rva_to_file_offset(names_addr, sections) ords_off rva_to_file_offset(ords_addr, sections) funcs_off rva_to_file_offset(funcs_addr, sections) exports [] for i in range(nnames): name_rva struct.unpack_from(I, data, names_off i * 4)[0] name _read_cstring(data, rva_to_file_offset(name_rva, sections)) ordinal_index struct.unpack_from(H, data, ords_off i * 2)[0] func_rva struct.unpack_from(I, data, funcs_off ordinal_index * 4)[0] exports.append({name: name, ordinal: base ordinal_index, rva: hex(func_rva)}) return exports这里唯一要绕的弯是「名字表」和「序号表」的关系第 i 个名字对应第 ordinal_index 个函数槽位而不是直接对应第 i 个函数。如果只按顺序把名字和函数地址一一对接解析出来的导出表在函数顺序被编译器调整过的地方会整体错位。base ordinal_index才是这个 DLL 对外导出的序号很多分析工具直接把这个值当作 API 的代号使用尤其在混淆过的样本中按序号调用是常见手法。4.3 输出分层报告从二进制到人类可读清单把上面两个函数串起来CFF_Explorer 就具备了一个 PE 查看器最核心的输出能力。我喜欢把报告设计成三层第一层是文件概览机器类型、节数、入口点、编译时间戳第二层是节表清单第三层是导入导出明细。导出成 JSON 的好处是后续可以接脚本、接规则引擎也可以直接丢给团队里的其他分析工具。{ file: sample.bin, machine: x64, entry_point: 0x140001000, sections: [ {name: .text, rva: 4096, vsize: 8192, raw_size: 8192}, {name: .rdata, rva: 12288, vsize: 4096, raw_size: 4096} ], imports: [ {dll: KERNEL32.dll, funcs: [VirtualAlloc, CreateThread]}, {dll: USER32.dll, funcs: [MessageBoxA]} ], exports: [ {name: DllMain, ordinal: 1, rva: 0x140001100} ] }这个 JSON 结构里「RVA」和「文件偏移」两个字段我建议同时保留。因为做静态分析时字符串和常量定位需要文件偏移而做动态调试、下断点时需要的是 RVA 或绝对地址。报告里只存一种坐标等要用的时候再换算往往会因为忘了转换而浪费半小时。报告生成后CFF_Explorer 的「Explorer」部分就真正落地了每一个 DLL、每一个函数名、每一个节权限都可以作为下一步深入分析的入口而不是停留在「打开文件看到一堆十六进制」的原始状态。5. CFF_Explorer 避坑指南偏移、位数、对齐与畸形文件以下五条排障经验来自我自己和团队在解析各种样本时的血泪记录每一条都对应一次真实翻车。它们分成两类一类是解析逻辑本身的问题一类是样本故意违背规范导致的异常。遇到怪问题时不妨按这个清单顺序排查。5.1 RVA 当成文件偏移直接读最经典的翻车现象是导入表解析出来 DLL 名全是乱码或者函数名里混着 \x00 和不可见字符。原因非常统一拿到数据目录里的 RVA 后没有经过 rva_to_file_offset 转换就直接当文件偏移去读字节。RVA 是程序加载进内存后的相对虚拟地址而文件偏移是它在磁盘文件里的物理位置两者只在头部区域恰好相等一进入节区域就分道扬镳。解决方式就是第 3 章里贴的转换函数所有目录类解析的入口都必须先过它。我的习惯是在转换函数里加一个「越界返回 None」的分支宁可返回空也不返回错误位置这样后续报错更容易定位。5.2 32 位与 64 位解析错位thunk 宽度决定导入表死活现象是 64 位样本的导入函数名解析出来错位每个名字后面都多出几个不可见字符。原因是 IMAGE_THUNK_DATA 这个联合体的大小在 32 位下是 4 字节在 64 位下是 8 字节同时指示「按序号导入」的最高位也从 0x80000000 变成了 0x8000000000000000。如果解析器在 x64 样本上仍按 4 字节读取 thunk 数组那么第 2 个函数开始全部对齐到错误位置。解决方式是在解析可选头时先读 Magic 字段0x10B 按 32 位处理0x20B 按 64 位处理顺便用 Machine 字段交叉验证一次两个标志不一致时直接告警提示文件被改动过。5.3 对齐不是玄学VirtualSize 与 SizeOfRawData 的选择现象是节内容的起始地址都对但节尾的数据读出来差几字节或者某些节的原始数据读不全。原因是每个节在内存中按 SectionAlignment 对齐在文件中按 FileAlignment 对齐两个值经常不一样。比如 SectionAlignment 固定 0x1000FileAlignment 可能是 0x200 或 0x1000。VirtualSize 是节在内存中的实际大小SizeOfRawData 是文件里实际存放的字节数两者可能不一致节的结束位置应该取两者较大值。解决方式就是在 rva_to_file_offset 的判断条件里用 max(vsize, raw_size)同时在读取节数据时做边界保护文件里没有对应 raw 数据的部分读到的只会是内存映射后的结果静态解析时应当跳过。5.4 畸形文件与资源目录死循环给解析器加三根保险丝现象是解析某个样本时程序卡死或者导入函数列表出现几千条重复记录。原因是恶意样本经常手工修改 PE 结构导入表描述符数组没有全零终止符资源目录树存在循环引用。解决方式我给解析器固定加三根保险丝第一所有描述符数组循环都设上限导入描述符最多 4096 项第二每个 RVA 在解析前校验是否落在节范围内不在就直接跳过第三单个样本的解析包在 try/except 里一个坏文件不能让批量任务整体崩溃。A同学当时跑一个疑似同源样本集一个畸形样本把整个扫描任务拖死加了这三根保险丝之后才真正敢把工具扔进批量场景。5.5 NumberOfRvaAndSizes 被改小数据目录读不全现象是延迟导入表、加载配置读不到或者导出表解析到一半停止。原因是可选头里的 NumberOfRvaAndSizes 字段记录数据目录实际项数编译器通常写 16但某些加壳器会把它改小以隐藏目录解析器如果盲目按 16 项去读读到的目录条目可能是无效数据。解决方式是先读这个字段实际目录项数取 min(NumberOfRvaAndSizes, 16)并且在读取每个目录项之前先判断它是否落在文件范围内。数据目录的项数不会影响节表的起始位置因为节表位置取决于 SizeOfOptionalHeader这两个字段不要混为一谈。6. 把 CFF_Explorer 变成命令行情报小工具6.1 批量遥测一次扫完整个目录的导入表手工分析一两个样本用第 4 章的解析器足够但面对一个文件夹几十个样本时手工就撑不住了。我一般会把 CFF_Explorer 的解析函数收拢成一个命令行批量工具遍历目录逐个样本提取导入表和导出表输出单一 JSON 报告。下面这个脚本采用常见的解析库来做快速扫描重点在于 fast_load 和选择性展开目录速度比全量解析快很多适合只关心导入导出信息的场景。import os import json import pefile def scan_directory(root_dir): report [] for root, _, files in os.walk(root_dir): for fn in files: path os.path.join(root, fn) try: pe pefile.PE(path, fast_loadTrue) pe.parse_data_directories(directories[ pefile.DIRECTORY_ENTRY[IMAGE_DIRECTORY_ENTRY_IMPORT], pefile.DIRECTORY_ENTRY[IMAGE_DIRECTORY_ENTRY_EXPORT], ]) imports [] for entry in getattr(pe, DIRECTORY_ENTRY_IMPORT, []): for imp in entry.imports: name imp.name.decode(latin1) if imp.name else fordinal_{imp.ordinal} imports.append({dll: entry.dll, func: name}) report.append({file: fn, imports: imports}) pe.close() except Exception as e: report.append({file: fn, error: str(e)}) with open(cff_explorer_report.json, w, encodingutf-8) as f: json.dump(report, f, indent2, ensure_asciiFalse)参数说明fast_load 只加载文件头和节表不展开任何目录parse_data_directories 用白名单精确展开导入导出两项比全量展开快得多而且不会在资源目录上被畸形数据拖死。单样本异常隔离在这里是必须的因为一个坏样本抛异常就会中断整个遍历加 try/except 并记录 error 字段批量任务才能安全跑完。6.2 用规则打分从导入表直接给样本分个险级批量报告生成后下一步就是做初筛。我习惯维护一个敏感 API 小表比如 VirtualAlloc、CreateRemoteThread、WriteProcessMemory、WinExec、RegSetValueExA 这些把每个样本导入表命中的数量累加成风险分分数高的样本优先人工分析。这个笨办法在实战里比想象中好使因为加壳样本的导入表往往被压缩或隐藏但没加壳的常见远控和投放器导入表会把它的行为意图暴露得干干净净。再往外接一层可以把每个样本的导入表指纹做哈希去重同源样本一眼就能认出来配合 YARA 规则时解析出的字符串和函数名还能直接塞进规则里做特征匹配。我现在的习惯是任何 PE 样本到手先把这批脚本跑一遍导入表、导出表、节权限一次刷完再决定要不要进调试器。这个习惯救过我好几回特别是面对一堆疑似同源样本时导入表一致性能省下大半天。希望帮到你。本文还有配套的精品资源点击获取