简介:pyinstxtractor是一套PyInstaller程序逆向提取工具,可从打包生成的Windows可执行文件中提取内部模块,并自动修复pyc文件头,使输出文件能直接交给主流字节码反编译器识别,可帮助还原源码逻辑、关键函数与内部数据结构。兼容Python 2.x与3.x,支持PyInstaller 2.0至4.2等常见版本,也可尝试解析版本略新或略旧的打包产物;运行时只需在命令行传入目标文件路径即可。提取出的pyc文件会保留模块全名与原始层级,配合反编译器可进一步还原函数逻辑、类定义和字符串常量,从而大幅缩短人工分析时间。资源包为一个压缩包,共3个文件,约18KB,包含主脚本、说明文档与开源许可,结构紧凑、即下即用。已有830人学习,适合CTF逆向、恶意样本分析、程序拆解等场景中的安全工程师与开发者。
1. pyinstxtractor 是什么:一条命令把 PyInstaller 打包的 exe 打回原形
拿到一个行为可疑的 exe,千万别直接双击。先查一下文件特征,如果末尾带着明显的归档痕迹,十有八九是 PyInstaller 打的包——Python 写的程序被塞进了自解压壳里,黑匣子式运行只会让你在虚拟机里反复处理恶意行为。pyinstxtractor 就是为这个场景服务的 PyInstaller 提取器:一条命令,把打包进 exe 的 Python 字节码(pyc)和资源文件全部拆出来,再由反编译器还原成近似源码。
它解决的不是「运行样本」,而是「看清样本内部」:入口脚本逻辑、模块引用、硬编码的 URL 和密钥,都能从拆出来的 pyc 里挖到。适合恶意样本分析、CTF 逆向、以及偶尔的源码抢救;不适合的场景是程序里掺了 Cython 编译的 pyd,那段逻辑只能按二进制对待,提取器帮不了你。本文从 PyInstaller 归档结构讲起,把「提取 → 补头 → 解编译 → 验证」整条链路一次走通,读完你应该能独立处理绝大多数 PyInstaller 样本。
2. PyInstaller 归档结构:为什么「提取」不是解压而是定位加拼装
先把工具放一边,搞清楚你要面对的文件长什么样。PyInstaller 的产物不是普通压缩包,它把 Python 运行时、依赖模块、入口脚本揉进了一个自定义的归档格式里,提取的本质是「定位归档 → 读 TOC → 逐条拼装」,而不是解压一个 zip。这一章把结构讲清楚,后面你遇到「提取出来但解编译失败」的情况时,才知道该往哪个方向排查。
2.1 onefile 产物:引导壳与追加在尾部的 CArchive
PyInstaller 默认的 onefile 模式产出是一个单 exe,它的逻辑布局非常固定:文件头部是一段 C 语言编译的 bootloader,负责在运行时把归档解压到临时目录(名称一般形如_MEIxxxx),再拉起内嵌的 Python 解释器;文件尾部则是真正的内容区,也就是 CArchive 归档。
[可执行文件整体] ├── 头部:C 语言 bootloader │ 运行时自解压,定位 Python 动态库并启动解释器 └── 尾部:CArchive 归档区 ├── 文件条目区:pyc、dll、配置文件等原始字节 ├── TOC:每个条目的名称 / 偏移 / 长度 / 类型 └── Cookie:8 字节魔数 + 包长度 + TOC 偏移等元数据CArchive 是 PyInstaller 自定义的格式,不是标准 zip。bootloader 运行时会从尾部读 Cookie,得到 TOC 的偏移和长度,再逐条把文件解到临时目录。静态分析时我们要做的就是重复这个动作,但不需要执行 bootloader——pyinstxtractor 恰恰是把这部分逻辑重新实现了一遍。另一方面,onedir 模式的依赖文件虽然散落在目录里,但主程序文件本身同样带有一段 CArchive,所以提取器对两种模式都适用,只是注意别漏掉目录里的附属文件。
2.2 8 字节魔数与 TOC:提取器的定位锚点
CArchive 的 Cookie 里有一个固定不变的 8 字节魔数,字面写法是MEI\014\013\012\013\016,这是 PyInstaller 格式的标志。pyinstxtractor 的工作起点就是扫描整个文件找这 8 个字节,找到后按 Cookie 结构读出 TOC 偏移和条目数量,再顺着 TOC 把每个条目拆出来,这就是整个提取器的核心原理。
Cookie 里值得关注的字段如下:
| 字段 | 含义 | 对提取的作用 |
|---|---|---|
| magic | 8 字节 MEI 魔数 | 定位归档起点 |
| lengthofPackage | 归档总长度 | 判断自解压的数据体量 |
| toc | TOC 偏移 | 读取条目列表的起点 |
| tocLen | TOC 长度 | 防止越界读取 |
| pyvers | Python 版本信息 | 反推 pyc 头的重要依据 |
TOC 里的每个条目都带类型标记,常见的有s(脚本,即入口)、m(模块)、b(二进制)、z(PYZ 包)。这解释了为什么提取器能直接打印出 Python 版本——它读的是归档元数据。但注意,PyInstaller 6.x 对 Cookie 结构有调整,原版 pyinstxtractor 在解析新版样本时可能失败,社区维护的 pyinstxtractor-ng 就是冲着这个兼容性去的,后面章节会提到。
2.3 PYZ 与裸 pyc:提取产物为什么不是完整 pyc
PyInstaller 会把纯 Python 模块集中打包成一个PYZ-00.pyz,它本质是一个 zip 文件,只是扩展名换了,里面的条目是各个模块的 pyc,路径和模块名对应,比如requests/adapters.pyc。pyinstxtractor 会顺手把这个 pyz 也解掉,输出到pyz_extracted目录。
关键问题在于:所有 pyc 在归档里都不是标准 pyc 文件。标准 pyc 有一个文件头,包含魔数、时间戳和原始文件长度,而 PyInstaller 在打包时把这个头剥掉了,只保留 marshal 序列化后的 code object。于是提取出来的东西是「裸字节码」,直接丢给任何反编译器都会报错。补头的长度随 Python 版本不同:Python 2 是 8 字节,Python 3.0 到 3.6 是 12 字节,Python 3.7 以后是 16 字节。这是整条链路里翻车率最高的环节,第 3 章专门展开。
2.4 为什么 strings 和 binwalk 在这里不够用
有人会问,既然归档是一个 zip,直接用 binwalk 扫不就行了?binwalk 确实能扫出PYZ-00.pyz的 zip 特征,但 PYZ 之外的入口脚本 pyc 是裸条目,binwalk 给不了语义;strings 能捞出一堆字符串,但拿不到代码结构,面对稍微混淆过的样本基本失效;而且 bootloader 自身带有大量字符串,误报率很高。pyi-archive_viewer是 PyInstaller 自带的查看器,可以列目录和导出条目,但提取、解 PYZ、补头这些步骤它不负责,还是得自己拼一遍。
所以选型结论很明确:pyinstxtractor 单文件、零依赖、跨平台,把「定位归档、解析 TOC、拆 PYZ、生成占位头」一次做完,是这条分析链路里性价比最高的起点。原版解析失败时换 pyinstxtractor-ng,而不是去手动拼 TOC。
3. 跑通第一次提取:命令、产物与 Python 版本判定
这一章是整篇的可抄作业核心。我默认你手上有一个 PyInstaller 打包的 exe,叫它sample.exe,下面按实际操作的顺序来,每一步都给出命令和说明,并解释为什么非这样做不可。
3.1 pyinstxtractor 的最小运行命令与输出目录
先把 pyinstxtractor.py 放到和样本同一个目录,用 Python 3 运行,脚本只依赖标准库,不需要 pip 安装任何东西。
# 用法:python pyinstxtractor.py <目标文件> python pyinstxtractor.py sample.exe # 成功后会在当前目录生成同名目录 ls sample_extracted提取成功后,目录结构大致如下:
sample_extracted/ ├── sample.exe.manifest ├── PYZ-00.pyz ├── pyz_extracted/ │ ├── requests/ │ │ ├── __init__.pyc │ │ └── adapters.pyc │ └── ... ├── app.pyc # 入口脚本,名字通常和打包前的脚本一致 ├── base_library.zip └── ...几点参数说明。第一,目标文件路径支持绝对路径和相对路径,输出目录名固定是「原文件名 +_extracted」,如果目录已存在会被覆盖,处理多个样本时注意别互相踩。第二,pyz_extracted里是 PYZ 中的模块 pyc,入口脚本 pyc 不在这个子目录里,而是直接放在顶层。第三,入口脚本文件名并不总是带.pyc后缀,TOC 里s类型条目的名字就是原始脚本名,可能叫app或别的,提取器按原名保存,所以看到顶层有个不带扩展名的文件,别奇怪,它就是入口。
控制台会打印 Python 版本信息和归档长度,把这些输出存下来,后面补头要用。
3.2 从产物反推 Python 版本:版本不对后面全错
pyc 头里的魔数只和 Python 版本相关,版本判断错了,补出来的头就是错的,反编译器会直接拒绝。反推版本有三个办法,按优先级来。
# 方法一:提取器控制台输出里通常会直接给出 Python version # 方法二:样本里的动态库名字直接暴露解释器版本 strings sample.exe | grep -E "python3[0-9]*\.dll" # 方法三:看 bootloader 相关字符串 strings sample.exe | grep -i "pyi-"看到python38.dll就是 3.8,python310.dll就是 3.10,非常直接。动态库这个方法最可靠,因为它来自 PyInstaller 打包时真正捆绑的解释器版本。如果样本里搜不到 dll 名,就用方法一或方法三的输出结合推断。还有一条笨但有效的路子:用嫌疑版本生成一个参照 pyc,比较头部行为——但这个放到 5.3 的避坑里细说。
版本确认后,记下来。这是后面所有步骤的基准,版本错一个次要版本号,解编译器都可能翻车。
3.3 补全 pyc 头:16 字节还是 12 字节
pyinstxtractor 提取出来的 pyc 自带一个占位头,可能是全 0,也可能带了一部分魔数,但都不能直接用于反编译。标准做法是:跳过占位头,用目标版本的魔数重新写一个完整头部。下面这个脚本假设目标版本是 Python 3.7 及以上,头长度是 16 字节。
#!/usr/bin/env python3 # fix_pyc_header.py import struct import sys import importlib.util def fix_header(src, dst): # 跳过提取器补的占位头,只保留 code object 部分 with open(src, 'rb') as f: body = f.read()[16:] # Python 3.7+ 的标准 pyc 头:魔数(4) + flags(4) + mtime(4) + size(4) # flags 写 0 表示 timestamp-based,mtime 和 size 写 0 不影响 marshal 读取 magic = importlib.util.MAGIC_NUMBER header = magic + struct.pack('<III', 0, 0, 0) with open(dst, 'wb') as f: f.write(header + body) if __name__ == '__main__': fix_header(sys.argv[1], sys.argv[2])用法是python fix_pyc_header.py app.pyc app_fixed.pyc。逻辑说明:importlib.util.MAGIC_NUMBER返回的是当前运行环境 Python 的魔数,所以这台机器的 Python 版本必须和样本目标版本一致,否则魔数照样错。struct.pack('<III', 0, 0, 0)补的 12 个字节分别是 flags、时间戳、原始大小,全部置零。
提示:flags 置 0 表示这不是 hash-based pyc,import 系统会按时间戳校验而不是内容哈希;对 marshal.load 来说这段内容是透明的,所以全 0 非常安全。如果目标版本是 Python 3.6 及以下,头长度是 12 字节,把上面的
[16:]改成[12:],struct 格式改成<III去掉一个字段即可,具体见 5.5。
3.4 用 marshal 验货:解编译前最便宜的体检
补完头别急着解编译,先用 marshal 把 pyc 读一遍,能读通说明头和偏移都对了,这是最便宜的体检方式。
#!/usr/bin/env python3 # check_pyc.py import marshal import sys with open(sys.argv[1], 'rb') as f: f.read(16) # 跳过标准 pyc 头,偏移必须和头的实际长度一致 code = marshal.load(f) print('co_filename:', code.co_filename) # 打包机器上的原始路径 print('co_names:', code.co_names[:20]) # 用到的全局名字 print('consts:', [c for c in code.co_consts if isinstance(c, str)][:10])出现ValueError: marshal data too short说明跳过的头长度不对;出现bad marshal data说明偏移错位,多半是头长度和实际不一致。co_filename是打包时机器上的路径,能透出模块的真实布局;co_names和co_consts能提前看到函数名和关键字符串,这本身就是情报。全部通过,才进入下一步解编译。
4. 从 pyc 到源码:解编译器匹配与结果判读
提取和补头都做对了,下一步是把 pyc 还原成近似源码。这个环节最反直觉的一点是:工具不是越新越好,而是必须和 Python 版本严格匹配。选错工具,轻则输出残缺,重则直接崩溃。
4.1 Python 版本决定工具:选错等于白解
目前主流解编译器的能力边界大致如下,这是我实际使用后的参考:
| 原始 Python 版本 | 首选工具 | 备注 |
|---|---|---|
| 2.7 | uncompyle6 | 老牌工具,对 2.7 支持成熟 |
| 3.6 | uncompyle6 | 输出基本可用 |
| 3.7 | decompyle3 | 比 uncompyle6 稳定 |
| 3.8 | uncompyle6 / decompyle3 | 两个都行,看具体样本 |
| 3.9 | pycdc | uncompyle6 已失效 |
| 3.10+ | pycdc | 输出可能不完整,需人工修补 |
说一句现状:uncompyle6 是事实上的老牌标准,但项目停滞在 Python 3.8 附近;decompyle3 是它的一个分支,专攻 3.7 到 3.8,对 3.7 的指令集处理得更好;pycdc 来自 Decompyle++ 项目,是目前对新版本 Python 支持最好的,但输出风格偏「C 和 Python 的混合体」,控制流结构经常走样。如果你要处理 3.11、3.12 的样本,pycdc 基本是唯一选择,同时要有手工修复的心理准备。
这个表的意义在于:先把版本定死,再选工具。看到 3.9 的样本别浪费时间试 uncompyle6,它的表现是「看似输出了内容,实则大片函数直接丢 None」。这不是玄学,是字节码变化导致的必然结果。
4.2 三种解编译器的落地命令
# uncompyle6:失败会报错并退出,别硬看输出 uncompyle6 -o recovered1 app_fixed.pyc # decompyle3:3.7 / 3.8 样本优先用它 decompyle3 -o recovered2 app_fixed.pyc # pycdc:需要先在本机编译,输出重定向到文件 ./pycdc app_fixed.pyc > recovered3.py命令都很简单,真正的经验在于失败时的判断。如果 uncompyle6 报bad magic number,回头检查 3.3 的补头步骤,而不是怀疑样本问题;如果 pycdc 输出了但大量内容是pass,说明这个版本的字节码它没完全识别,换不了工具,只能靠人工补。解编译器不是格式化工具,它是在猜测原本的源码结构,猜不出来时用pass占位是正常现象。
还有一个实用习惯:解编译前先看 marshal 读出的co_names有多少个名字是None。如果None占比高,说明代码对象里有很多难以映射的操作,解编译结果大概率要修。
4.3 验证解编译结果:常量对齐与可运行性
还原出来的源码怎么确认没跑偏?我的标准动作是对常量表。原始 pyc 里的字符串常量是跑不掉的,解编译后的源码里必须能找得到。
import marshal with open('app_fixed.pyc', 'rb') as f: f.read(16) code = marshal.load(f) # 把原始字节码里的字符串常量拉出来,人工确认是否出现在还原源码里 expect = [c for c in code.co_consts if isinstance(c, str)] for s in expect: if len(s) > 8: print(s)更进一步的验证是语法和运行层。语法上用python -m py_compile recovered3.py检查,能通过说明代码结构完整;运行层上,把还原出的脚本放在同版本 Python 里 import 并执行关键函数,比对行为是否和样本一致。恶意样本的还原源码通常能跑通主逻辑,但沙箱逃逸、反调试这类功能会缺失,这是解编译的固有损失,不是你的操作问题。
5. pyinstxtractor 使用避坑:5 条高发问题与对策
这章是我踩坑最多的地方,每一条都按「现象 → 原因 → 解决」写,遇到对应问题可以直接对号入座。
5.1 杀毒软件把提取产物当样本隔离
现象:pyinstxtractor.py 一运行就被实时防护拦下,或sample_extracted目录生成后很快被整个隔离,文件消失。
原因:PyInstaller 壳特征本身就和高危样本高度重合,加上提取目录里有大量可执行字节和 zip,杀软按行为特征直接判毒。
解决:所有提取操作放在隔离虚拟机里做,不使用宿主机的实时防护;如果必须在工作机操作,给工作目录加白名单,提取完立即用压缩包把产物收起来归档。我的习惯是样本分析永远在专用虚拟机里进行,既是为了 AV 干扰,也是为了不把恶意代码扩散到工作环境。
5.2 UPX 壳导致找不到 MEI 魔数
现象:运行 pyinstxtractor 提示找不到归档,或直接报错Failed to determine PyInstaller version之类的信息,但文件确实是 PyInstaller 产物。
原因:样本外层被 UPX 压缩过,bootloader 的入口段被压缩改写,8 字节 MEI 魔数被破坏或偏移被改变,静态扫描直接扑空。
解决:先用 UPX 解密还原,再提取。
# 判断是不是 UPX:看文件末尾是否有 UPX 标记 strings sample.exe | grep -i UPX # 脱壳 upx -d sample.exe # 脱壳成功后再跑提取 python pyinstxtractor.py sample.exe需要注意,UPX 脱壳依赖原程序的导入表信息,有少数样本脱不了,这时静态提取基本无解,只能运行样本后用内存 dump 的方式把归档从内存里抠出来,这是另一种工作量,不在本文范围。
5.3 补了全 0 头仍然报 bad magic
现象:按 3.3 补完头,解编译器还是报bad magic number,或者 marshal 能读但反编译器拒绝。
原因:你补进去的魔数来自运行 pyinstxtractor 的那台机器的 Python 版本,而不是打包样本的 Python 版本。两台机器版本不一致,魔数就错。
解决:先用 3.2 的方法把目标版本钉死,然后找一个与目标版本一致的 Python 环境运行补头脚本。这里有个实用技巧:临时创建一个包,用目标版本的 Python 编译出一个 pyc,把它的前 16 字节直接复制到提取产物上,比依赖importlib.util更直观。
# 在目标版本的 Python 环境下生成参照 pyc python -c "import py_compile; py_compile.compile('dummy.py', cfile='ref.pyc'); print(open('ref.pyc','rb').read(16).hex())"拿到参照头的 hex 后,替换补头脚本里的 magic 字段即可。补头这事没有后悔药,错了就重新补一次,成本很低,真正浪费时间的往往是没确认版本就硬解。
5.4 PYZ 加密条目:encrypted__ 前缀
现象:pyz_extracted里出现encrypted__开头的 pyc 文件,解编译出来全是乱码或 marshal 直接失败。
原因:打包时用了--key参数,PyInstaller 会对 PYZ 里的模块做 AES-CBC 加密,加密后的文件在提取时会带上encrypted__前缀。
解决:原版 pyinstxtractor 无解,需要用pyinstxtractor-ng并手动提供密钥。
# pyinstxtractor-ng 支持 --key 参数,key 是 32 字节的十六进制串 python pyinstxtractor-ng.py sample.exe --key 目标密钥密钥从哪来是另一个问题。常见做法是动态运行样本,在内存里搜索 32 字节的密钥特征,或者从样本字符串里定位密钥相关的派生逻辑。加密包的整条提取流程比普通包多一个「找密钥」前置步骤,如果没有明确线索,可以考虑直接放弃静态还原,改成动态行为分析,后者往往更快。
5.5 老包的头长度不是 16 字节
现象:Python 2 或 Python 3.6 的样本,按 16 字节跳过头部后 marshal 报错,补头也不对。
原因:pyc 头的长度是随版本演化的。Python 2 是 8 字节(魔数 + 时间戳),Python 3.0 到 3.6 是 12 字节(魔数 + 时间戳 + 大小),Python 3.7 之后才固定为 16 字节。老样本用新规则处理,偏移直接错位。
解决:用二分试探确定正确的头长度,再按对应规则补头。
import marshal for skip in (16, 12, 8): with open('app_old.pyc', 'rb') as f: f.read(skip) try: code = marshal.load(f) print('header size =', skip, 'OK') break except Exception as e: print('skip', skip, 'failed:', e)注意:Python 2 的 marshal 数据在 Python 3 里无法直接加载,因为 marshal 格式本身是版本专用的。确认是老样本的话,最好准备一个 Python 2.7 环境去补头和解编译,uncompyle6 在 2.7 下反而最稳。
6. 进阶:把「提取 → 补头 → 解编译」做成一条自动化流水线
单样本手动操作没问题,但批量分析时每步都手动太累。我一般会把整条链路串成一个脚本,输入一个 exe 目录,输出一份「哪些解出来了、哪些卡住」的报告,然后只对卡住的人工介入。
import glob import marshal import os import subprocess import sys def fix_header_inplace(pyc_path): # 复用 3.3 的逻辑,原地覆盖占位头 with open(pyc_path, 'rb') as f: body = f.read()[16:] import struct, importlib.util header = importlib.util.MAGIC_NUMBER + struct.pack('<III', 0, 0, 0) with open(pyc_path, 'wb') as f: f.write(header + body) def pipeline(exe): # 1. 提取 subprocess.run(['python', 'pyinstxtractor.py', exe], check=True) out = exe + '_extracted' # 2. 遍历所有 pyc(含 pyz_extracted 子目录),补头 + 验货 + 解编译 for pyc in (glob.glob(os.path.join(out, '*.pyc')) + glob.glob(os.path.join(out, 'pyz_extracted', '**', '*.pyc'), recursive=True)): fix_header_inplace(pyc) try: with open(pyc, 'rb') as f: f.read(16) marshal.load(f) except Exception: print('skip (marshal):', pyc) continue # 3.9+ 用 pycdc,低版本换成 decompyle3 py = pyc + '.py' with open(py, 'w', encoding='utf-8', errors='replace') as out_f: subprocess.run(['pycdc', pyc], stdout=out_f) pipeline(sys.argv[1])参数说明:check=True让提取步骤失败就立即中断,避免后续处理空目录;glob用递归模式把pyz_extracted里的嵌套模块也覆盖到;解编译时按 4.1 的版本矩阵替换工具名。遇到marshal失败的文件直接跳过并打印路径,最后人工看报告即可。
加密包的批量处理思路一样,只是把第一步换成pyinstxtractor-ng.py并带上--key。验证方法我沿用 4.3 的规则:跑得起来且关键常量对得上才算成功,否则宁可不信这份还原结果。
这套流程跑多了之后,我反而越来越依赖最笨的两步:先strings确认版本和是否 UPX,再marshal验货。序列乱了,后面全白工;序列对了,解编译器基本不会骗你。希望这条链路能帮你在下次遇到 PyInstaller 样本时少走几趟弯路。
本文还有配套的精品资源,点击获取