简介:PyInstaller打包的可执行文件快速还原工具,面向需要分析或复用Python程序的开发人员、安全审计人员及编程学习者,可自动完成从可执行文件中提取pyc字节码、再将其反编译为原始Python源码的两步流程,显著降低逆向还原门槛。工具包共6个文件,以Python脚本和Markdown文档为主体,包含主程序、解包依赖及使用说明,压缩后仅9KB,无需额外安装第三方库,开箱即用。使用时只要本地Python版本与目标可执行文件一致,即可通过类似‘python exe2py.py index.exe’的简单命令行一键启动全流程,适合离线或受限场景下的源码还原。该工具已有102人学习,适用于开发调试、代码审计和学习分析,对未混淆、未加壳的PyInstaller程序还原效果良好。资源内目录结构清晰,主程序、解包脚本与说明文档相互分离,便于按需查阅与二次修改,是初中级开发者理解字节码转换与反编译流程的实用参考。
1. PyInstaller打包的exe文件还原Python源码:先拆包,再反编译
拿到一个 PyInstaller 打包的 exe,想找回它的 Python 源码脚本,这件事没有想象中那么玄。PyInstaller 的打包本质是归档不是加密,exe 里躺着的就是完整的 Python 字节码,还原路径只有两步:把字节码从 exe 里剥出来,再反编译回 .py。整个过程不需要 IDA 级别的反汇编手段,一个拆包脚本加一个反编译工具就够。下面按这条 exe反编译 路线往下走,覆盖工具选型、命令行参数、入口脚本补头,以及 3.9 以上新版 Python 的兼容性取舍。适合三类人:丢了源码的开发者、做安全审计的测试者,以及想搞懂 python转exe文件 背后机制的逆向新手。
2. 拆开PyInstaller打包的exe:用pyinstxtractor提取pyc的完整流程
2.1 打包产物结构:CArchive、PYZ与入口脚本
说到 python生成exe可执行文件,PyInstaller 是绕不开的名字。很多人打包时用的顺手,但很少反过来想它的产物到底长什么样。PyInstaller 不编译也不加密代码,它只是把 Python 解释器动态库、依赖模块的字节码和入口脚本一起塞进一个自解压归档里。用--onefile打包成单个exe 时,exe 尾部追加了一段 CArchive 归档,里面装着 python3xx.dll、依赖模块聚合成的 PYZ 归档、入口脚本对应的 pyc,以及 PyInstaller 的运行时引导代码。exe 每次运行,bootloader 先把归档释放到临时目录再导入模块,跑完再清理,这也是为什么单文件 exe 启动总是慢半拍。
这段结构直接决定了还原思路:源码其实一直完整存在于 exe 内部,只是从 .py 变成了 .pyc,还原就是把 .pyc 抠出来再转回 .py。这里有个关键区别必须分清:PYZ 归档里的依赖模块带完整 pyc 文件头,解出来后可以直接反编译;而入口脚本的 pyc 文件头是被清零存放的,需要额外补一道头。很多人第一次还原就卡在入口脚本上,本质上就是没区分这两类文件,拿处理模块 pyc 的流程去处理入口,必然报错。另外,--onedir模式只是把归档拆到了外部 .pkg 文件里,提取思路完全一样,只是目标从单个 exe 换成目录里那个 pkg。
2.2 用pyinstxtractor.py提取exe:一行命令与输出目录解读
拆包的标准工具是 pyinstxtractor.py,新版 PyInstaller 和 Python 3.9+ 场景建议直接上社区维护的 pyinstxtractor-ng。这类工具的工作方式并不神秘:在 exe 二进制里定位 CArchive 的魔数标记,找到归档起点后按内部索引把每个文件原样切出来。整个过程在本地完成,不需要联网,对只敢碰离线环境的审计任务很友好。命令只有一个参数:
python pyinstxtractor.py C:\tools\app.exe不需要指定输出目录,跑完会在 exe 同目录生成app.exe_extracted文件夹。终端末尾出现[+] Successfully extracted to ...就算成功;如果提示找不到归档结构,优先怀疑 exe 被 UPX 压缩过,解法在第 4 章。Windows 用户注意,如果命令行里python指向的是微软商店占位符,换成py -3 pyinstxtractor.py或者用你打包时用的那个解释器路径。
提取目录里的文件分三类。顶层散落的 pyc 是运行时引导代码和入口脚本;base_library.zip与PYZ-00.pyz是标准库和第三方依赖的 zip 归档,里面全是带完整头的 pyc;配置文件、资源文件也原样躺在目录里。判断哪个 pyc 是入口脚本,最可靠的办法是看打包时的入口文件名——app.py打出来的 exe,提取目录里一定有一个app.pyc。如果入口名不确定,取顶层体积最大的散装 pyc 当入口,命中率很高,因为业务入口的代码量通常远大于引导模块。
2.3 反编译前第一件事:核对Python版本
提取完成先别急着反编译。反编译工具对字节码版本极敏感,3.8 和 3.9 是两个完全不同的世界,版本判断错了,后面每一条报错都会让你怀疑人生。先随手挑一个模块 pyc 读出魔数,用PYZ-00.pyz解出来的ctypes.pyc就行:
with open('PYZ-00.pyz_extracted/ctypes.pyc', 'rb') as f: magic = f.read(4) print(magic.hex()) # 例如 610d → CPython 3.9把输出对照下面的速查表,这是从 3.6 到 3.11 的常见取值:
| Python版本 | pyc魔数hex(小端) |
|---|---|
| 3.6 | 330d |
| 3.7 | 420d |
| 3.8 | 550d |
| 3.9 | 610d |
| 3.10 | 6f0d |
| 3.11 | a70d |
读出的值不在表里时,优先怀疑这个 exe 不是 PyInstaller 打的,或者打包机用的 Python 版本太新。版本号决定反编译工具的选择:3.8 及以下用 uncompyle6,3.9 及以上要换 pycdc 组合。这一步花两分钟能省两小时,属于典型的血泪经验——我在 3.9 时代用 uncompyle6 死磕过一整晚,最后发现只是工具选错了。补头时也要用同版本的参考 pyc,混用版本会让反编译产物彻底乱掉。
3. 从pyc还原到可读源码:uncompyle6、decompyle3与pycdc的组合
3.1 三条工具线的适用边界:版本决定选择
反编译 pyc 的工具就三条线。uncompyle6 历史最久,支持到 CPython 3.8,对 3.6-3.8 的还原质量最好;decompyle3 是它的分支,主打 3.7/3.8 的稳定性,处理 try/except 这类复杂控制流时出错率比旧版低;pycdc 用 C++ 实现,是 3.9 及以上字节码的主力选择,但对复杂控制流的还原精度不如低版本场景下的 uncompyle6。选择规则就一句话:版本越低用 uncompyle6,版本越高越往后排。用错工具的症状高度统一,报UNKNOWN opcode然后中断,或者产出明显缺函数的不完整代码,几乎没有第三种表现。
工具选型时还要考虑一个现实因素:uncompyle6 多年没大更新,pycdc 一直在跟进新字节码,但功能对等不等于质量对等。如果你要还原的是 3.10/3.11 的产物,做好心理准备——pycdc 能给你一份结构大体正确的代码,但注释、f-string 细节、部分推导式会走样。3.8 时代的还原体验确实是最好的,这也是为什么很多审计报告里都备注"建议项目锁定 Python 3.8"。
3.2 反编译单个pyc:uncompyle6与decompyle3的标准命令
确认 pyc 版本 ≤ 3.8 后,安装并反编译:
pip install uncompyle6 uncompyle6 -o ./decompiled app.pyc-o指定输出目录,不带-o时结果直接打到 stdout,可以配合重定向存文件。成功时终端会显示Successfully decompiled;失败时最常见两条报错:bad marshal data说明 pyc 头部有问题(对应 3.3 的补头操作),Unknown opcode说明工具版本没对上。uncompyle6 在 3.7/3.8 上偶尔也会翻车,这时换 decompyle3 重跑一次:
pip install decompyle3 decompyle3 -o ./decompiled app.pyc它俩的命令参数几乎一样,切换成本为零。一个实用习惯是反编译时带上验证开关,uncompyle6 支持在输出后自动重新编译比对:
uncompyle6 --verify -o ./decompiled app.pyc带上--verify后,工具会把还原出的代码重新编译,再与原始 pyc 的字节码做比对,并在终端提示是否一致。这一步能帮你过滤掉"长得像源但行为不等价"的劣质产物,建议默认打开。
3.3 入口脚本补头:让 main.pyc 起死回生
入口脚本 pyc 的头部在打包时被清零,直接反编译必然报bad marshal data。修复方法是从同版本普通 pyc 借一个完整头部覆盖上去。Python 3.7 及以后 pyc 头固定 16 字节:魔数 4 字节 + flags 4 字节 + mtime 4 字节 + 源文件大小 4 字节。补头脚本如下:
with open('PYZ-00.pyz_extracted/ctypes.pyc', 'rb') as f: header = f.read(16) with open('main.pyc', 'rb') as f: body = f.read()[16:] # 丢掉被清零的16字节占位头 with open('main_fixed.pyc', 'wb') as f: f.write(header + body)补完后用 marshal 验证一次,确认它真的是个可解析的 code 对象:
import marshal with open('main_fixed.pyc', 'rb') as f: data = f.read() code = marshal.loads(data[16:]) print(code.co_name, len(code.co_code))能打印出函数名和字节码长度,说明头部对了,可以交给反编译工具。如果入口 pyc 开头不是完整 16 字节零,改成按 12 字节偏移处理,那是 Python 3.6 的头长度。
提示:补头用的参考 pyc 必须和入口 pyc 来自同一版本解释器,混用魔数会让反编译产物直接变乱码。
这一步是整个还原流程的核心,跑通它,一个几千行的业务入口脚本就在这十几行代码里复活。
4. PyInstaller还原源码的避坑指南:5个高频翻车点
做 PyInstaller 打包的 exe 反编译,坑不是一般的多。下面五条按出现频率排,每条都按"现象 → 原因 → 解决"写,对照排查能少走很多弯路。
4.1 反编译中断报 UNKNOWN opcode:版本错配
现象:uncompyle6 跑到一半终止,抛ValueError: UNKNOWN opcode。原因:pyc 的字节码版本比工具支持的新,最典型是 3.9 的 pyc 撞上 uncompyle6;另一个常见原因是入口 pyc 没补头,marshal 数据整体错位,工具把偏移后的数据当操作码解。解决:先按 2.3 的速查表核实版本,3.9 及以上换 pycdc;3.8 及以下换 decompyle3 重跑。在这件事上别跟一个工具死磕,换工具比查日志快得多。
4.2 入口 pyc 报 bad marshal data:头部没补
现象:模块 pyc 反编译全正常,唯独 main.pyc 报bad marshal data,用十六进制编辑器打开发现开头是一排00。原因:PyInstaller 把入口脚本 pyc 的标准头清零存放,工具按正常 pyc 解析自然失败。解决:执行 3.3 的补头脚本,借同版本普通 pyc 的头重建。这条坑占所有排查里的一半以上,凡是 main 开头的 pyc 出问题,先补头再说,别去研究什么玄学。
4.3 提取失败或产物乱码:exe 被 UPX 压缩过
现象:pyinstxtractor 提示找不到归档魔数,或者提取成功但 pyc 文件打开全是不可读字节。原因:打包流程里开了 UPX 压缩,exe 尾部归档的定位信息被改写,拆包脚本无从下手,或者定位到了但内容已经被压扁。解决:先脱壳再提取:
upx -d C:\tools\app.exe python pyinstxtractor.py C:\tools\app.exeupx -d偶尔会提示not packed,说明压缩时用了改动过的 UPX 头部,这种基本只能手动在二进制里搜归档魔数一段一段抠,性价比极低,建议直接放弃换目标。
4.4 反编译出来只有几十行:核心逻辑被 Cython 或 PyArmor 藏起来了
现象:main.pyc 成功反编译,但产物是一堆对pyarmor_runtime或_cython扩展的调用,业务代码不在里面。原因:打包前用 Cython 把核心模块编译成 pyd/so,或用 PyArmor 对字节码做了加密,pyc 里只剩一个壳。解决:识别封装方式后别在 pyc 上硬耗。Cython 产物去分析 so 的导出符号,PyArmor 产物只能走动态调试。安全审计场景下,不如直接在干净的虚拟机里跑 exe,抓它的文件读写和网络行为。这是还原的边界,不是失败。
4.5 反编译产物行为对不上:工具还原精度有限
现象:还原出的 py 语法检查通过,一跑就抛异常,或输出结果和 exe 实际行为不一致。原因:反编译器对 try/except/finally、异步代码、部分推导式的还原存在已知短板,生成的代码"长得像源但不等价",这在复杂业务脚本里尤其常见。解决:用 pycdc 对同一 pyc 交叉反编译一份,关键函数做行为对照——同一输入分别喂给 exe 和还原代码,比对输出是否一致。这条坑提醒一句:还原结果必须验证,不能直接当原始源码用。
5. 批量化还原的工程化:把exe反编译做成一条流水线
5.1 提取+补头+反编译的一键脚本
处理单个 exe 手敲命令还好,手上有一批 exe 要还原时,必须把流程脚本化。先写提取与入口定位:
import os, subprocess, sys EXTRACTOR = "pyinstxtractor-ng.py" def restore_entry(exe_path): extract_dir = exe_path + "_extracted" if not os.path.exists(extract_dir): subprocess.run([sys.executable, EXTRACTOR, exe_path], check=True) pycs = [p for p in os.listdir(extract_dir) if p.endswith(".pyc") and not p.startswith("pyi-")] entry = max(pycs, key=lambda p: os.path.getsize(os.path.join(extract_dir, p))) return os.path.join(extract_dir, entry) if __name__ == "__main__": for exe in sys.argv[1:]: print("processing", exe) print(restore_entry(exe))入口选择用的启发式是"顶层最大 pyc",对绝大多数打包配置有效;如果打包时入口脚本被改名,这个判断会失手,兜底方案是把pycs列表全部打印出来人工挑。EXTRACTOR 指向你本地的拆包脚本路径,Windows 上建议用绝对路径,避免当前工作目录影响。
反编译阶段枚举所有 pyc 逐个尝试,把失败项落盘:
import glob for pyc in glob.glob("C:/tools/app.exe_extracted/*.pyc"): r = subprocess.run(["uncompyle6", "--verify", "-o", "restored", pyc], capture_output=True, text=True) if r.returncode != 0: open("fail.log", "a").write(f"{pyc}: {r.stderr}\n")带上--verify能顺带过滤不等价产物;把 stderr 写进日志是排错的关键,反编译失败的原因几乎全在里面。
5.2 还原质量的三个验证手段:编译、检索、对照
反编译产物到手,先过三道验证关。第一道是语法与可编译性:
python -m py_compile restored_main.py能通过说明至少语法完整。第二道是特征串检索,从 exe 上用 strings 命令提取 URL、SQL、报错文案,再回还原代码里 grep:
grep -nE "https?://|password|secret|token" restored_main.py交集越大,还原质量越高。第三道是行为对照,在干净环境跑 exe,记录文件读写和网络请求,和还原代码里出现的路径、域名逐一核对。三道都过,这份还原代码才值得信任。
5.3 面对3.9+新版字节码:pycdc与pycdas兜底
3.9 以上的 pyc,uncompyle6 基本退役,主力是 pycdc 和配套的 pycdas,pycdc 是 C++ 项目,需要本地编译。pycdc 输出可读代码:
pycdc main_fixed.pyc > main_restored.pypycdc 对 3.9-3.11 的主流语法支持得不错,但 f-string 细节、注解和部分异常处理会丢失。当 pycdc 也失败时,pycdas 是最后防线,它输出字节码层级的伪汇编:
pycdas main_fixed.pyc > main_dis.txt从 dis 文件里读常量表、函数名和调用序列,虽然慢,但能手工还原关键逻辑。3.10 以上 exe 的还原率确实在下降,这是字节码演进带来的客观事实,不是工具没选对。
6. 还原后的源码值不值得信:验证方法与真实边界
还原完成只是开始。我一般用三个交叉验证确认还原质量:第一,行为对照——在虚拟机里跑原始 exe,记录它读写了哪些文件、访问了哪些域名,再回还原代码里找这些痕迹,全对得上才算数;第二,字符串交集——对 exe 跑一遍 strings,拿到的 URL、SQL、硬编码密钥去还原代码里搜,命中率低于八成就要警惕;第三,编译往返——还原代码 py_compile 后,用 dis 模块对比原始 pyc 的常量表和操作码序列,结构一致说明还原基本可信。
也要说清真实边界:注释、空行、格式化永远回不来;函数名和局部变量名大部分保留在 co_varnames 里,但 3.11 的帧栈优化可能让部分局部变量变成_0这类占位名;3.10 以上的还原产物可能需要手工修补才能运行。所以这份"源码"的正确用法是辅助理解与审计,而不是百分之百还原成当初提交的那份 .py。我自己的教训是:头一年做还原,总想找一把万能工具一步到位,结果在 3.9 上翻车无数次。后来老老实实按"版本识别 → 拆包提取 → 入口补头 → 工具交叉验证"四步走,反而又快又稳。这套流程现在是我拿到任何 PyInstaller 产物的第一动作。希望帮到你。
本文还有配套的精品资源,点击获取