EXE文件解压指南:PE结构、overlay与嵌套载荷提取
简介:这是一款面向开发者、逆向工程学习者与软件资源分析人员的EXE文件解压工具,核心用途是提取可执行程序及部分安装包内部的图像、文本、音频与二进制资源,帮助查看程序组成、调试素材或开展教学分析。压缩包为rar格式,共167个文件,约5.14MB,以txt说明文档、exe主程序与解包组件、dll动态库、unp及wcx插件为主,并配有ini配置、doc文档、htm帮助页和图标等,整体围绕多引擎解包与格式兼容组织。已有3511人学习下载,在EXE资源提取与结构分析场景中具有一定参考价值。包内还包含语言配置、许可协议、变更记录以及bin、docs等目录,读者可据此了解运行依赖和使用说明,完成资源提取、结构查看与素材复用;使用时需遵守软件许可与版权规定,仅限合法合规的研究与分析。
1. EXE 文件解压不是反编译:先看容器再谈逆向
拿到一个 EXE,双击能跑,安装前改了哪些文件、图标和资源能不能复用、卸载后残留什么,很多人第一反应是找反编译。实际更靠前的一步是 EXE 文件解压:把可执行文件当成容器,先把资源段、安装载荷、压缩数据和附加在文件尾部的数据完整取出来,再决定要不要看代码。EXE 可执行文件解压工具解决的正是这个前置问题,它适合做软件资产迁移、安装包巡检、故障排查和授权范围内的安全分析,不适合拿来做绕过授权的事。判断一个 EXE 值不值得继续拆,先看它是不是 PE、节区里有没有大块资源、文件尾部有没有 overlay,这三步比盲目上反编译器稳得多。
2. PE 结构与资源定位:三个偏移决定解压入口
EXE 文件解压最容易翻车的地方,是把“解压”当成“双击后看它往外释放什么”。静态解包的第一步是认容器:这个文件是不是 PE,节表在哪里,资源目录指向哪一段,文件尾部有没有附加数据。PE 不是压缩包,它没有统一的“解压口令”,但它的结构足够规整,规整到用几十行脚本就能把关键偏移列出来。我一般先跑一个只读检查,把节区、overlay 和字符串样本落到 JSON 里,再决定用图形入口看资源树,还是用命令行入口批量跑。
2.1 先认 PE:MZ、e_lfanew 与节表
PE 文件开头两个字节是MZ,偏移0x3c处的 4 字节是e_lfanew,它指向真正的 PE 签名PE\0\0。PE 签名后面是 COFF 头,里面有两个关键字段:NumberOfSections表示节区数量,SizeOfOptionalHeader表示可选头长度。节表紧跟在可选头后面,每项 40 字节,字段包括节名、虚拟大小、虚拟地址、原始大小、原始偏移。很多人只看节名找.rsrc,但节名可以改,真正可靠的入口是可选头里的数据目录:资源目录是索引 2,每项 8 字节,前 4 字节是 RVA,后 4 字节是大小。PE32 和 PE32+ 的数据目录起始偏移不同,PE32 从可选头+96开始,PE32+ 从+112开始,所以脚本不要硬编码节表位置,直接用SizeOfOptionalHeader推算。
| 字段 | 常见偏移/来源 | 用途 | 容易误判的点 |
|---|---|---|---|
e_lfanew | DOS 头0x3c | 找 PE 签名 | 不要假设 PE 头固定在0x80 |
NumberOfSections | COFF 头+2 | 节区数量 | 为 0 不代表不是 PE,但通常异常 |
SizeOfOptionalHeader | COFF 头+16 | 算节表起点 | PE32/PE32+ 长度不同,必须读 |
PointerToRawData | 节表项+20 | 文件中的真实数据偏移 | 不要和VirtualAddress混用 |
SizeOfRawData | 节表项+16 | 文件中的真实数据长度 | 为 0 的节不占文件空间 |
| 资源目录 | 数据目录索引 2 | 定位.rsrc类资源 | 节名可能被改,不能只看名字 |
上表里最值得盯的是PointerToRawData和SizeOfRawData。解压工具从文件里取数据,用的是原始偏移和原始大小;调试器看内存布局,用的是虚拟地址和虚拟大小。把这两组值搞混,写出来的提取脚本就会读错段,轻则产物缺文件,重则把 PE 头当成资源导出。我的习惯是先把每个节区的raw_ptr、raw_size、vaddr、vsize打成表,看到.rsrc的raw_size明显大于普通资源节,再重点看资源目录。
2.2 资源段不是唯一目标:overlay 与嵌套载荷
资源段是结构化资源,图标、位图、版本信息、清单文件、对话框模板都可能在里面,图形化解压工具通常能把它们导成独立文件。但安装包和单文件打包器经常把真正的载荷放在 overlay:最后一个节区的原始数据结束到文件末尾之间的附加数据。overlay 不是垃圾,很多自解压包把压缩流、脚本段、校验信息都放在这里。你如果把 overlay 当填充字节截掉,外层 EXE 可能还能打开,但解出来的安装器会缺载荷,运行到一半报错。
另一个常见位置是资源段里的RCDATA或自定义资源类型。它们可能是压缩流,也可能是嵌套的 PE 文件。判断方法不复杂:先看资源大小和头部字节,如果前两个字节是MZ,大概率是嵌套 EXE 或 DLL;如果是一段高熵数据,可能是压缩流或加密载荷,需要工具包里的二级识别入口继续处理。这里不要迷信字符串扫描,字符串只能提供线索,不能当结论。安装器标识、临时目录名、压缩库名这些字符串很有用,但误报也高,最后还是要回到偏移和大小。
2.3 用一段 Python 把节区、overlay 和可疑字符串列出来
下面这段脚本不依赖第三方库,直接按 PE 结构读节表,算 overlay 偏移,并抽样打印可打印字符串。把它存成check_pe.py,作为解压前的固定检查。
import struct, pathlib, re, json, sys def u16(b, o): return struct.unpack_from('<H', b, o)[0] def u32(b, o): return struct.unpack_from('<I', b, o)[0] def inspect_pe(path): p = pathlib.Path(path) data = p.read_bytes() if len(data) < 0x40 or data[:2] != b'MZ': raise ValueError('不是 MZ 开头') e_lfanew = u32(data, 0x3c) if data[e_lfanew:e_lfanew + 4] != b'PE\0\0': raise ValueError('PE 签名不在 e_lfanew 指向的位置') coff = e_lfanew + 4 num_sections = u16(data, coff + 2) opt_size = u16(data, coff + 16) opt = coff + 20 magic = u16(data, opt) sections = [] sec_off = opt + opt_size for i in range(num_sections): o = sec_off + i * 40 sections.append({ 'name': data[o:o + 8].rstrip(b'\0').decode('latin1', 'replace'), 'vsize': u32(data, o + 8), 'vaddr': u32(data, o + 12), 'raw_size': u32(data, o + 16), 'raw_ptr': u32(data, o + 20), }) # overlay 从最后一个节区的原始数据末尾开始 end = max((s['raw_ptr'] + s['raw_size'] for s in sections), default=0) overlay = data[end:] text = data.decode('latin1', 'ignore') strings = sorted(set(re.findall(r'[ -~]{6,120}', text)))[:80] return { 'file': str(p), 'size': len(data), 'pe_offset': e_lfanew, 'optional_magic': hex(magic), 'sections': sections, 'overlay_offset': end, 'overlay_size': len(overlay), 'overlay_head_hex': overlay[:32].hex(), 'strings_sample': strings, } if __name__ == '__main__': print(json.dumps(inspect_pe(sys.argv[1]), ensure_ascii=False, indent=2))脚本里的e_lfanew用来跳转到 PE 签名,num_sections和opt_size用来算节表起点,避免把 PE32 和 PE32+ 的可选头长度写死。raw_ptr + raw_size取最大值得到 overlay 起点,overlay_head_hex打印前 32 字节,方便判断它是MZ头、压缩流头还是纯填充。strings_sample限制 80 条、每条 6 到 120 个可打印字符,目的是抽样看安装器标识和临时目录名,不是做完整字符串导出。运行时传一个 EXE 路径即可,输出 JSON 可以直接贴进样本登记清单。看到overlay_size大于 0 时不要急着截掉,先单独复制一份 overlay 再继续。
3. 解压流程落地:从只读副本到产物清单
真正开始解压时,最怕的不是工具报错,而是工具“成功”了但产物对不上。我的流程固定成三步:原始样本只读备份,工作目录按哈希命名,解压入口和产物目录分离。图形入口适合看单个文件的资源树,命令行入口适合批量跑和留日志。不管用哪个入口,都先假设它会写出不可预期的东西,所以工作目录要独立,样本要只读,产物要带清单。
3.1 只读副本与样本登记:先把后悔药备好
原始样本不要直接扔进解压工具。先复制到工作目录的input子目录,改成只读权限,再算一次 SHA-256,把文件大小、修改时间、来源备注写进manifest.json。工作目录名用<文件名>_<哈希前8位>,这样同一批样本不会互相覆盖,出了问题也能回到原始副本。只读权限不是为了防病毒,是为了防手滑:有些工具会回写样本、改时间戳、在原目录释放临时文件,源文件一旦被改,后面所有对比都失去基准。
样本登记还要记录工具入口和参数。图形入口至少记下工具名称、版本、勾选项;命令行入口把完整命令、退出码、标准输出和标准错误落到run.log。不要只留一句“解压成功”,因为后面发现缺文件时,你需要知道当时用的是自动识别还是强制模式,有没有禁止执行载荷,临时目录设在哪里。这个动作看着笨,但它是后面排查的基准。
3.2 图形入口和命令行入口:什么时候别用“双击”
图形入口的优势是直观:资源树、图标预览、版本信息、导出按钮都在一个界面里,适合单文件排查和资源复用。但图形入口不适合批量,也不适合审计,因为你很难记录每一次点击。命令行入口更适合批量:输入、输出、模式、超时、日志都能参数化,跑完一批还能生成失败列表。
这里有一个边界要提前说清:不要用“双击运行”代替静态解包。双击自解压包,程序可能先释放到临时目录,运行完再删掉,你看到的只是它想让你看到的界面。正确做法是把样本复制到只读工作目录,用工具包的静态解包模式,把临时目录指向工作目录下的temp,并且关闭“解压后自动打开产物”这类选项。工具入口的参数名可能不同,但逻辑一样:输入只读副本,输出独立目录,禁止执行载荷,保留临时文件。
3.3 批量解压脚本:参数、产物命名与失败重试
批量脚本不要直接拼命令行字符串,路径里有空格和中文时很容易翻车。用列表传参,工作目录用pathlib管理,每个样本一个任务目录,失败时保留现场。下面是一个可抄的骨架,把entry换成工具包实际命令行入口即可。
import hashlib, json, shutil, subprocess, sys, pathlib, datetime def sha256(path): h = hashlib.sha256() with open(path, 'rb') as f: for chunk in iter(lambda: f.read(1024 * 1024), b''): h.update(chunk) return h.hexdigest() def prepare(sample, workroot): sample = pathlib.Path(sample) workroot = pathlib.Path(workroot) digest = sha256(sample) job = workroot / f'{sample.stem}_{digest[:8]}' input_dir = job / 'input' out_dir = job / 'out' temp_dir = job / 'temp' for d in (input_dir, out_dir, temp_dir): d.mkdir(parents=True, exist_ok=True) dst = input_dir / sample.name shutil.copy2(sample, dst) dst.chmod(0o444) # 只读副本,防止工具回写源文件 manifest = { 'source': str(sample), 'copy': str(dst), 'sha256': digest, 'size': dst.stat().st_size, 'started_at': datetime.datetime.now().isoformat(timespec='seconds'), } (job / 'manifest.json').write_text( json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8' ) return job, dst, out_dir, temp_dir def run_unpack(entry, sample, out_dir, temp_dir, mode='auto'): cmd = [ sys.executable, str(entry), '--input', str(sample), '--out', str(out_dir), '--temp', str(temp_dir), '--mode', mode, '--no-exec', # 只解包,不执行载荷 '--keep-temp', # 失败时保留临时目录 ] p = subprocess.run( cmd, capture_output=True, text=True, timeout=600, ) return { 'cmd': cmd, 'returncode': p.returncode, 'stdout': p.stdout[-4000:], 'stderr': p.stderr[-4000:], } if __name__ == '__main__': sample = sys.argv[1] entry = pathlib.Path(sys.argv[2]) job, dst, out_dir, temp_dir = prepare(sample, pathlib.Path('work')) result = run_unpack(entry, dst, out_dir, temp_dir) (job / 'run.json').write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8' ) print(job)prepare负责复制只读副本、算哈希、建input/out/temp三个目录,manifest.json记录样本来源和起始时间。run_unpack用列表传参,避免空格和中文路径被 shell 拆坏;timeout=600是单样本上限,批处理时可以按文件大小调整;--mode auto让工具自动识别容器类型,识别失败时再换强制模式;--no-exec是关键参数,表示只做静态解包,不运行解出来的载荷;--keep-temp用于失败现场保留。脚本只截取标准输出和标准错误的最后 4000 字符,防止日志把磁盘打满。跑完先看returncode,非 0 不要急着重跑,先看run.json里的stderr和temp目录。
4. 安装包与单文件打包:识别信号与分层提取
EXE 文件解压的难点不在“能不能打开”,而在“打开的是哪一层”。同一个 EXE 可能是安装器、自解压包、单文件打包器、资源封装器,也可能是普通程序加了一层压缩壳。不同层对应的提取策略不一样:安装器要找脚本段和压缩载荷,自解压包要看 overlay 和临时目录,单文件打包器要看资源段里的嵌套 PE。识别信号比盲试更省时间。
4.1 安装包类:资源段、脚本段与压缩载荷的信号
安装包类 EXE 通常有几个信号:资源段里有安装器标识字符串,节区里出现脚本段或压缩库名,文件尾部 overlay 明显大于普通程序,解压后能看到.tmp、.dat、.cab之类的中间文件。图形入口一般能直接展开资源树,但脚本段和压缩载荷不一定在资源树里,需要命令行入口的“深度扫描”或“载荷识别”模式。我的做法是先看overlay_size,再看资源段里最大的几个条目,最后用字符串样本交叉验证。
| 观察点 | 可能信号 | 说明 | 建议动作 |
|---|---|---|---|
| overlay 大于 1MB | 自解压或安装载荷 | 不是填充数据 | 单独复制 overlay,记录偏移和大小 |
| 资源段异常大 | 图标、脚本、压缩资源 | 资源目录可能有多层 | 导出资源树,按类型和大小排序 |
| 字符串含安装器标识 | 安装包构建器 | 可能有脚本段 | 找脚本段和压缩流,不要只看资源 |
| 解出文件含临时后缀 | 运行时释放 | 静态解包可能不完整 | 在只读沙箱观察临时目录 |
| 解出内层 MZ 头 | 嵌套 EXE/DLL | 需要递归 | 设置深度上限,记录层级 |
这张表不能替代实际分析,但能帮你决定先看哪一层。比如 overlay 很大、资源段很小,优先把 overlay 单独拿出来识别;资源段很大、overlay 很小,优先导出资源树;两边都不大但解出内层 PE,说明是嵌套结构,进入递归流程。不要一上来就把所有模式都勾上,模式越多,产物越难对账。
4.2 单文件打包与自解压:临时目录和 overlay 的配合
单文件打包器常把依赖和资源压进一个 EXE,运行时释放到临时目录再加载。静态解包时,如果工具只导出资源段,可能会漏掉 overlay 里的压缩流;如果只导 overlay,又可能漏掉资源段里的配置。正确做法是两层都看:先导出资源树,再单独保存 overlay,最后用文件头识别 overlay 是不是压缩流或嵌套 PE。自解压包还要注意临时目录,工具包里如果有“模拟运行”或“临时目录捕获”模式,输出目录一定要指向工作目录下的temp,不要让它写到系统临时目录,否则清理时找不到现场。
这里有一个容易忽略的点:解压出来的文件权限和时间戳可能被工具改写。做资产迁移时,时间戳影响不大;做完整性对比时,时间戳和权限就要单独记录。我的习惯是产物目录只读保存,另建一个report目录放清单,清单里同时记录原始样本哈希、产物路径、产物大小和产物哈希。这样即使工具改写了时间戳,也能用哈希判断文件内容有没有变。
4.3 递归扫描嵌套 EXE:深度上限与产物树
嵌套 EXE 很常见:外层安装器解出内层安装器,内层再解出实际程序。只解一层就以为完整,后面运行缺 DLL 时又要回头补。递归扫描的关键是限制深度,防止压缩炸弹和无限嵌套。下面这段脚本只做识别和标记,不自动执行,适合挂在批量解压之后。
import pathlib, struct def is_pe(path): with open(path, 'rb') as f: head = f.read(0x40) if len(head) < 0x40 or head[:2] != b'MZ': return False e_lfanew = struct.unpack_from('<I', head, 0x3c)[0] if e_lfanew <= 0 or e_lfanew > 0x10000000: return False f.seek(e_lfanew) return f.read(4) == b'PE\0\0' def walk_pe(root, depth=0, max_depth=3): root = pathlib.Path(root) for p in sorted(root.rglob('*')): if not p.is_file(): continue if p.suffix.lower() not in {'.exe', '.dll', '.bin', '.dat', '.tmp'}: continue if is_pe(p): print(' ' * depth + f'{p} size={p.stat().st_size}') if depth < max_depth: # 这里调用工具入口,把 p 解到 p.parent / (p.stem + '_unpack') # 不要在这段脚本里执行 p pass if __name__ == '__main__': walk_pe('work', max_depth=3)is_pe只读文件头,先看MZ,再跳到e_lfanew检查PE\0\0,不会把整个文件读进内存。walk_pe只扫描指定后缀,max_depth=3是递归上限,超过就只记录不展开。注释里的调用入口要换成工具包实际命令行入口,并且保持“只解包、不执行”。产物树建议按layer0/、layer1/、layer2/分层存放,每层附一个layer.json记录父文件、偏移、工具参数和退出码。这样后面发现缺文件时,能快速定位是哪一层漏了。
5. 避坑与排查:五个让产物对不上的现场
这一章是我自己踩过的血泪经验。EXE 文件解压的失败很少表现为“工具崩溃”,更多是“工具说成功,但产物对不上”。下面五个现场按出现频率排序,每条按现象、原因、解决写,方便你直接对照日志。
5.1 现象:解压后目录几乎是空的
现象是工具返回成功,out目录只有几个图标和版本信息,没有安装载荷。原因通常是把自解压包当普通资源文件处理了,真正的载荷在 overlay 或临时目录里,工具只导出了资源段。解决方法是先看overlay_size,把 overlay 单独复制出来,再用深度扫描模式识别;同时检查工具是否开启了“仅资源”模式,改成“自动识别”或“容器解包”。如果样本是自解压包,不要双击运行,双击释放到系统临时目录后可能自动清理,现场就没了。把临时目录参数指向工作目录下的temp,失败时保留。
5.2 现象:安装器被“瘦身”后无法启动
现象是解出一个安装器 EXE,大小比原样本小很多,双击报缺少载荷。原因是为了减小产物,把 overlay 截掉了,或者只导出了资源段里的脚本,没有把压缩流一起带出来。解决方法是保留完整 overlay,记录overlay_offset和overlay_size,产物目录里单独放一份overlay.bin;如果工具支持“重组”模式,按原偏移写回,再校验文件大小和哈希。不要用“删除填充”这种选项处理安装包,填充和载荷在文件尾部经常挨在一起,删错一段就少一块。
5.3 现象:批量任务一半成功一半 0 字节
现象是同一批样本,有的产物正常,有的文件 0 字节,日志里只有一句权限错误。原因常见于路径拼接:样本名带空格或中文,命令行被 shell 拆成多个参数,工具把后半段当成了输出目录;也可能是只读副本权限设置过早,工具需要写临时文件时被拒绝。解决方法是改用列表传参,不要拼字符串;只读权限只加在input副本上,out和temp保持可写;批量脚本里给每个任务独立目录,失败后不要直接覆盖,保留run.json和temp。看到 0 字节先查命令参数,再查磁盘空间和文件句柄。
5.4 现象:只解出一层,运行仍缺 DLL
现象是外层解压成功,内层 EXE 也能打开,但运行时报缺少 DLL 或配置文件。原因是嵌套结构只解了一层,内层 EXE 的资源段或 overlay 里还有一层载荷。解决方法是跑递归扫描,按layer0/layer1/layer2分层存放,每层记录父文件和偏移;递归深度不要无限放大,max_depth=3足够覆盖大多数安装器,超过上限的先人工看文件头和字符串。不要用“自动执行解出来的 EXE”来验证完整性,那会把静态分析变成运行分析,风险和工作量都上一个台阶。
5.5 现象:哈希对不上、时间戳全变
现象是产物内容和预期一致,但哈希清单对不上,时间戳也全变了。原因是解压工具在写出文件时重写了时间戳、权限或换行符,部分工具还会给资源文件加头部。解决方法是把“内容哈希”和“元数据”分开记录:内容哈希用 SHA-256,元数据单独记原始修改时间、产物修改时间、权限位;对比时先比大小和哈希,再比时间戳。如果工具支持“保留原始时间戳”就打开,不支持就接受元数据差异,不要为了时间戳一致去改产物内容。源文件哈希在解压前后各算一次,确认只读副本没有被回写。
6. 完整性验证:哈希、字符串与时间线三件套
解压完不要只看目录里有没有文件,我一般做三件套验证:哈希、文件树、时间线。哈希解决“内容有没有变”,文件树解决“层级有没有漏”,时间线解决“失败能不能复现”。这三件事都不复杂,但能把很多“感觉解压成功了”变成可对账的结果。
| 验证项 | 做法 | 判定 |
|---|---|---|
| 源文件哈希 | 解压前后各算一次 SHA-256 | 两次一致,说明只读副本没被改写 |
| 产物清单 | 记录路径、大小、SHA-256、层级 | 与manifest.json和layer.json对得上 |
| overlay | 记录偏移、大小,单独保存 | 不为空时不能丢,必要时参与重组 |
| 字符串样本 | 扫描安装器标识、临时目录名 | 与解压日志交叉验证,不单独下结论 |
| 时间线 | 记录开始、结束、工具入口、参数、退出码 | 失败时能按同样参数复跑 |
我现在的固定动作是:原始样本先复制只读副本,算一次哈希;解压前再算一次,确认副本没变;解压后生成产物文件树和哈希清单,和run.json放在同一个任务目录;overlay 不为空就单独留一份,并在清单里写清偏移和大小。这个习惯看着笨,但能省掉很多后悔药。那次批量任务里有一个安装器解出来能打开、安装到一半才报缺文件,最后就是靠 overlay 偏移和分层清单定位到第二层漏解。从那以后我每次拿到 EXE 都先走一遍只读副本、哈希、overlay 检查,再决定用图形入口还是命令行入口。资源包和示例脚本放在文末获取入口,拿到后先跑只读副本流程,再按自己的样本调整递归深度和超时参数。希望帮到你。
本文还有配套的精品资源,点击获取