
简介这是一款面向 Python 开发者的逆向辅助工具专门用于快速还原 PyInstaller 打包生成的 Windows 可执行文件。工具内置 pyinstxtractor 与 uncompyle6 两阶段处理流程可在命令行中一键完成从 exe 提取 pyc 到反编译为 py 源码的操作适合本地调试、代码审计、学习分析等场景对未混淆、未加壳的 PyInstaller 产物效果较好使用时需注意本地 Python 版本与目标 exe 的构建版本保持一致。资源包共 6 个文件以 Python 脚本、说明文档和配置文件为主其中 exe2py.py 为主程序pyinstxtractor.py 负责解包另附 README 与依赖清单整体仅 9KB无需额外安装第三方库即可运行。已有 102 人学习使用适合具备基础 Python 命令行操作经验的读者快速上手也可作为理解 pyc 结构、反编译流程与 PyInstaller 打包机制的入门参考。1. 先把话说清楚PyInstaller 产物不是加密是一个可以拆开的包裹作为写过爬虫脚本、也给别人交付过桌面工具的工程师你一定遇到过这种情况当初打包好的xxx.exe还在但生成它的那份.py源码已经随换电脑和重装系统彻底消失了或者你接手了一个只有 exe 的“黑盒工具”想看看里面到底调了哪些接口、有没有后门逻辑。PyInstaller 打包的 exe 文件快速还原为 Python 源码脚本工具解决的就是这种“只给 exe、不给源码”的尴尬——PyInstaller 本质上是把 Python 解释器、依赖库和你写的脚本按一种自解压归档格式塞进了 exe它不是真正的编译加壳所以里面内容的可还原性远比很多人想象得高。这个方向适合有 Python 基础、想从 exe 里找回.py文件的人也适合做安全分析时快速审计一段陌生工具的逻辑。先说结论还原做不到 100% 和原稿一模一样但拿到能读懂、能改、能重新打包的源码级别文件是完全现实的。2. 还原前必须搞懂的两张底牌PyInstaller 的打包结构与 pyc 魔数2.1 打包结构你找的源码不在代码区在归档区大多数人对“exe 反编译”的第一反应是打开十六进制编辑器、在机器码里找字符串这套路对 C/C 写的程序勉强成立对 PyInstaller 产物完全无效。PyInstaller 的 exe 由一个 C 语言编写的 bootloader 引导程序和附加在其后的归档数据拼接而成。exe 运行时bootloader 先自解压归档把内部文件释放到临时目录再启动内嵌的 Python 解释器去执行真正的入口脚本。这意味着你要找的“.py 源码”根本不参与机器码编译它只是被压缩储存起来。与其说这是反编译不如说是拆包加反编译字节码。归档部分又分成两块一块是 CArchive存放主程序、动态链接库、以及入口脚本编译后的.pyc另一块是 PYZ 归档存放所有被 import 的模块依赖以.pyz形式内嵌。用 PyInstaller 打包时它的控制台输出里那句Building PYZ和Building EXE指的就是这两步。还原源码时主程序.pyc和依赖模块的.pyc都会一起提取出来只是藏在不同的位置。PyInstaller 打包还有一个关键分支--onefile和--onedir。onedir 模式会生成一个文件夹里面是_internal目录加主 exe归档结构相对透明onefile 模式则把所有东西硬塞进单文件启动时再释放提取工具的输入对象也相应不同。热词里常见“pyinstaller 打包成单个 exe”这类单文件最难处理的不是提取而是释放出来的临时目录里经常有运行时才生成的依赖提取时如果 exe 没有完整跑过一遍部分.pyc会缺失。所以第一步先确认目标 exe 是哪一种模式会直接影响后面的还原策略。2.2 pyc 魔数与 PyInstaller 对头部的修改拿到.pyc之后事情远没结束因为 PyInstaller 为了让自己的加载逻辑能识别在打包时对.pyc头部做了特殊处理标准 Python 编译出的.pyc文件以 4 字节 magic number 开头紧接着是版本控制信息和时间戳而 PyInstaller 归档里的.pyc头部会被移除或改写magic 信息由 PyInstaller 自身的运行时携带。直接把提取出来的.pyc丢给uncompyle6或者pycdc反编译通常会报错“未知的魔数”。想验证这一点你可以用任意一个标准 Python 解释器手写一个小脚本并py_compile生成一个普通.pyc然后用十六进制工具和 exe 里提取出来的.pyc做头 16 字节比对。比对结果会很清楚普通.pyc有完整头提取出来的.pyc前 8 字节通常是空的或者被填成 00。所以反编译前的准备工作就是给这些“无头”的.pyc补上当前解释器同小版本的完整头部。这一步做得不对后面所有反编译都是白费功夫。Python 小版本直接影响还原工具选型。如果目标 exe 是用 Python 3.7 打的包你用 3.10 环境去补头、反编译magic 对不上工具会直接拒绝处理。常见做法是用十六进制编辑器或strings搜索 exe 内的Python字样通常能在 bootloader 附近看到类似Python 3.8.8这类版本标识。拿不准时先准备两个常用版本的解释器环境备用是这一行做久了的血泪经验。2.3 onefile 与 onedir 模式下提取路线的差异处理 onedir 模式的 exe 时我能直接在_internal目录里看到大量.pyc因为 PyInstaller 打包后依赖模块会平铺在文件夹里提取工具做的工作就变成“从目录中解析出 pyc 并修正命名”。而 onefile 模式则必须先把整个 exe 当作归档入口由工具拆出 CArchive再从 CArchive 里拆出 PYZ 和内部文件。两种模式使用的工具其实相同但 onefile 多一层解包动作且启动一次 exe 再提取的成功率更高因为运行时生成的.pyc会出现在临时目录中可以一并拷出来补齐缺失模块。还有一个容易被忽略的点exe 的图标、版本信息、manifest 都是从 bootloader 里带的资源段读取的和源码无关不用花时间分析。真正有价值的只有归档区里的.pyc和那些.pyd文件后者是 C 扩展编译产物无法还原成 Python 源码只能通过接口逆向。这决定了还原工作的边界整个工具里如果只有少量.pyd那主逻辑基本都能还原如果关键的加密和协议处理全写在了.pyd里能拿回的 Python 源码价值就会大打折扣。3. 从 exe 里把 pyc 扒出来pyinstxtractor 与环境准备3.1 环境准备为什么 3.8/3.10 的坑不一样开始动手前先把 Python 环境备好。我会准备一个干净的解释器版本尽量与目标 exe 的打包版本一致因为后面修复 pyc 头部时magic 必须精确匹配小版本不一致也会导致反编译工具拒绝工作。常见的做法是先用strings app.exe | grep -i python查一下版本标识再决定装哪个解释器。实际运行时你会发现工具链里有三样东西是必须的pyinstxtractor.py负责拆包uncompyle6或pycdc负责把 pyc 翻译成源码以及一个能生成普通.pyc的参考脚本用它的头部来做修复模板。对 Python 3.8 和更早版本uncompyle6基本可用但 3.9 以上就得指望pycdc。pyinstxtractor 本身是个单文件脚本不需要安装也不需要联网把脚本和 exe 放同一目录直接运行就行这正是“pyinstaller 离线”场景下最好用的解法。3.2 跑 pyinstxtractor 的最小命令与产出解释假设目录下有tool.exe和pyinstxtractor.py执行下面的命令python pyinstxtractor.py tool.exe运行完会在当前目录生成一个名为tool.exe_extracted的文件夹。这个文件夹里你会看到三类东西一类是.pyc后缀的提取文件通常是入口脚本和少量贴近主逻辑的模块一类是PYZ-00.pyz文件是所有依赖模块的压缩合集还有一类是.pyd、.dll这些原生二进制。逻辑说明pyinstxtractor 的工作原理是扫描 exe 尾部的归档索引识别出 PyInstaller 的 CArchive 格式把归档里的每一个内部文件按原始偏移量切出来。它不执行 exe 里的任何代码所以目标文件是否是恶意程序也不影响提取过程。参数上只有-h之类的可选帮助项不用指定输出目录默认就是exe名_extracted这算是这个工具最省心的地方也是我推荐它的原因之一。下一步是把 PYZ 里的依赖模块也拆出来。pyinstxtractor 虽然提取了.pyz文件但不会自动解包里面的内容。这时可以用 pyinstxtractor 自带的另外一个模式或者手动调用 Python 内置的zipfile思路去处理.pyz。更常见的做法是先把PYZ-00.pyz改名成pyz.zip再用解压工具解开里面的每个模块同样是“无头 pyc”需要统一修头。这一步是还原整个工具内部逻辑时必需的否则入口脚本能打开但 import 的依赖全缺代码根本跑不起来。3.3 两类 pyc 的关系入口脚本与按需加载模块提取完成后的.pyc里入口位置很重要。PyInstaller 打包时会把入口脚本比如main.py编译成main.pyc放在归档根目录下和PYZ平级。要用哪个.pyc开始还原一眼就能判断名字带.pyc且不在 PYZ 里的基本就是入口坐在入口脚本里 import 的模块则全在 PYZ 之中。一个让新手犯迷糊的点是.pyz里解出来的模块文件名和源码里的 import 路径是严格对应的但 pyc 文件没有保留行号和缩进以外的额外元信息原本的注释和空行在编译时就已经被丢弃。所以反编译出来的代码里不会有注释这是字节码层面的物理事实接受这一点比纠结“为什么不完整”更重要。还原目标应当是“逻辑等价”的源码而不是“长得一模一样”的原稿。# 把 PYz 归档解开的标准操作 cp PYZ-00.pyz pyz.zip mkdir pyz_extracted cd pyz_extracted unzip ../pyz.zip这个命令说明之所以要手动改名加解压是因为 PyInstaller 的 PYZ 本质是一个 ZIP 容器但扩展名不是.zip许多解压工具不认。改扩展名只是为了骗过工具识别不对文件本身做额外修改。解压后的目录结构就是模块的包路径树入口脚本里写from utils.helper import foo在这里就能找到utils/helper.pyc。4. 把 pyc 变成能看的 .pypycdc / uncompyle6 选型与手动修头4.1 先修头再反编译从同版本解释器拿魔数这是整个还原流程里最容易翻车、也最值得花时间的一步。修复头部的思路是用同小版本 Python 生成的普通.pyc的头 16 字节覆盖到被提取出来的.pyc文件开头。Python 3.7 之后的.pyc标准头由 4 字节 magic、4 字节 bit field 或时间戳、4 字节源码长度、以及 4 字节打包信息组成。PyInstaller 归档里的 pyc 头通常只剩后面部分magic 被清零所以需要整个覆盖前 16 字节。我自己会写一个很短的小脚本目录结构正好也能在批量场景下复用import struct import pathlib import py_compile import sys import tempfile def build_reference_header(python_version_tag: str f{sys.version_info.major}.{sys.version_info.minor}) - bytes: # 生成一个与当前解释器同版本的标准 pyc 头 dummy pathlib.Path(tempfile.gettempdir()) / _ref_ref.py dummy.write_text(x 1\n, encodingutf-8) py_compile.compile(str(dummy), cfilestr(dummy.with_suffix(.pyc)), doraiseTrue) data dummy.with_suffix(.pyc).read_bytes() return data[:16] def patch_pyc_header(pyc_path: pathlib.Path, ref_header: bytes) - None: # 只覆盖前 16 字节保留 pyc 里的代码对象部分 data pyc_path.read_bytes() if len(data) 16: raise ValueError(f{pyc_path} 文件过短可能不是有效 pyc) pyc_path.write_bytes(ref_header data[16:]) if __name__ __main__: header build_reference_header() for path in pathlib.Path(.).rglob(*.pyc): patch_pyc_header(path, header) print(fpatched {path})逻辑说明py_compile.compile负责生成与当前解释器完全匹配的标准 pycref_header取出前 16 字节作为魔数模板遍历目标目录下所有*.pyc用模板覆盖每个文件的前 16 字节。覆盖动作不是插入而是替换因为 PyInstaller 提取出的 pyc 的前 18 字节本来就是无效占位数据直接替换不会破坏文件长度结构。参数说明ref_header[:16]的设计是因为 Python 3.7 的 pyc 头部固定为 16 字节低于 3.7 的旧版本则需要 12 字节如果你的目标 exe 还在用 Python 2.7 或 3.6 打包把切片长度改回 12 即可。脚本里用了rglob(*.pyc)会把 pyz 解开后的子目录也一并覆盖到不用分别处理。4.2 uncompyle6 与 pycdc 的分工修完头之后就可以开始反编译了。工具选择上有一条很明显的分界线Python 3.8 及以下uncompyle6的还原质量高、误报少Python 3.9 及以上uncompyle6基本全线罢工这时换成pycdc。pycdc是 C 写的反编译器对 3.9、3.10、3.11 的支持度都还行但对部分复杂控制流比如嵌套的 try/except/finally可能输出不完整的 AST。所以我的处理习惯是先跑uncompyle6如果报错或结果看着不对再跑pycdc对比两者结果。下面是两种工具的命令行写法# 用 uncompyle6 反编译单个 pyc输出到 decompiled 目录 uncompyle6 -o decompiled ./main.pyc # 用 pycdc 反编译结果打印到终端 pycdc ./main.pyc main_restored.py逻辑说明uncompyle6的-o参数指定输出目录直接按原 pyc 名字生成.py文件pycdc默认往标准输出打结果所以要用重定向到文件。两个工具都依赖 pyc 内部完整的代码对象结构这就是之前修头工作的意义所在。如果头没修好pycdc会直接打印ERROR: Unsupported or invalid magic number这个报错特征非常明显看到它第一反应不是换工具而是回头检查头 16 字节是否覆盖成功。参数说明uncompyle6有一个--verify参数会在反编译完把生成结果重新编译成字节码和原始 pyc 比对一致性。我在核心模块上会开着--verify跑但对几十个模块的整包还原全开验证速度太慢通常只对入口和对逻辑可疑的文件开。pycdc没有类似的验证机制只能靠人读结果判断有没有丢逻辑。另外如果手头机器没法装工具链还有在线反编译网站能做兜底把修好头的 pyc 上传就能拿到结果。这类在线工具质量参差不齐我一般留作最后手段不当作主路径。4.3 反编译后的整理入口脚本、分离依赖、重构文件名批量还原完成后你会得到一堆.py文件文件名的来源是 pyc 名和源码里的包路径不一定一致。举例来说utils/helper.pyc还原出来的helper.py如果直接从decompiled目录拷到项目根目录原脚本里的from utils.helper import foo会查找utils/helper.py目录对应不上就会报 ImportError。所以整理阶段的第一件事是把decompiled下的产物按原始目录结构放回去。# 从 pyc 所在目录层级把还原出的 .py 放回对应路径 mkdir -p restored/utils cp decompiled/helper.py restored/utils/helper.py逻辑说明pyinstxtractor提取出的结构里PYZ解压后的子目录已经体现了包路径反编译时也最好保持这个目录相对结构不要全部平铺到一个目录。这样入口脚本还原后import 关系可以直接用省去大批量改from ... import ...的麻烦。参数上没有什么可调的核心原则是“保持目录结构和 pyc 的包路径一致”。这一步里最大的工作量往往是入口脚本和主逻辑模块的修复。反编译出来的代码逻辑基本完整但变量名如果是混淆过的PyInstaller 本身不混淆但如果打包前用了别的混淆工具那还原回去也是混淆过的样子。整理阶段我会先跑一遍python -m py_compile确认语法没问题再逐个模块读代码把明显断裂的 import 补上。对于 PyInstaller 自动生成的启动逻辑比如PyInstaller打进来的 loader 相关代码可以直接删掉那些不是我们要的业务源码。5. 还原过程中的 6 个常见坑与排查方法5.1 坑一提取出来的 pyc 反编译直接报“未知魔数”现象pyinstxtractor 提取顺利main.pyc也在但无论用 uncompyle6 还是 pycdc都提示 magic number 不支持或无效。原因提取的 pyc 头部被 PyInstaller 清掉了魔数和时间戳你拿到的是一份“无头”字节码直接丢给反编译工具当然识别不了。解决本文 4.1 的修头脚本就是正解。注意脚本生成的参考头来自当前解释器如果提取的 pyc 对应 Python 3.8而你用的解释器是 3.10头修完依然无法反编译。先去 exe 里确认打包版本再选择对应的参考解释器这是所有反编译操作的前提。5.2 坑二uncompyle6 一跑就崩溃或报未知 opcode现象反编译 Python 3.9 以上版本的 pycuncompyle6 可能直接抛异常或者输出一段满是乱码的 opcode。原因uncompyle6 很长时间没跟着 Python 版本更新对 3.9 新增的字节码指令集覆盖不完整遇到不认识的操作码直接放弃或输出错误结果。解决放弃 uncompyle6切到 pycdc。如果 pycdc 也失败检查 pyc 的头部 16 字节里第 4 到第 8 字节的版本标志是否和你的解释器一致。用struct模块读一下前 4 字节和importlib.util.MAGIC_NUMBER做对比不一致就重新修头。5.3 坑三反编译“成功”但代码全是...和pass现象pycdc 没有报错输出的.py文件结构看起来正常但方法体内部大量出现...占位符或pass。原因pycdc 对某些复杂控制流尤其是异常处理、生成器、协程、多级嵌套装饰器反编译不完整把无法还原的表达式折叠成占位符。解决把...片段对应的源码块提取出来用原始字节码手动还原。具体做法是用dis.dis打印这个函数的字节码逐条分析局部变量和跳转位置虽然累但能拿回真实的逻辑。如果你对这段逻辑无所谓选择直接补一个等价的自定义实现也行。最关键的是不要因为看到满屏...就以为是失败先确认入口逻辑是否完整。5.4 坑四补头后文件长度变了pycdc 直接崩溃现象自己写的修头脚本执行完再用ls -l看文件大小发现每个 pyc 比以前大了 16 字节但 pycdc 反而崩溃。原因修头时用了“插入”而不是“覆盖”把本来只有魔法数缺失的文件强行拉长破坏了后面 code object 的偏移量计算。pyc 内部各个段落的偏移是相对文件头开始的长度变了整个解析全部错位。解决重新从原始提取目录复制一份未修改的 pyc用write_bytes(ref_header data[16:])这种覆盖式写法重做。修头脚本里加一条断言确保文件长度不变再写回磁盘。这个坑我踩过一次之后所有批量处理脚本都会打印修改前和修改后的文件大小做校验。5.5 坑五打包时用了--keypyc 文件内容是乱码现象提取出来的 pyc 即使修好头用任意反编译工具输出依然是一团不可读的二进制或乱码。原因PyInstaller 支持--key参数在打包时会对字节码做 AES 加密运行时由 bootloader 内部解密。没有 key逆不出来这和头部缺失是两码事。解决这种情况可以放弃字节码还原路径。如果 exe 本身允许运行可以通过运行时 hook 的方式在 Python 解释器加载完模块后把__code__对象 dump 出来再用反编译工具处理。但这套操作复杂度和成功率都不乐观作为经验遇到--key打包的目标先评估值不值得投入时间。5.6 坑六还原出的代码没法跑入口缺失或路径错误现象反编译几乎没报错代码看着也完整但用 Python 一跑就报ModuleNotFoundError或FileNotFoundError。原因还原出来的是逻辑源码但没有把打包时的资源文件、配置文件、以及.pyd依赖一起还原。入口脚本通常依赖sys._MEIPASS指向的临时目录读取数据文件这个路径在源码环境里不存在。解决从 exe_extracted 目录里把非 pyc 的资源文件比如.json、.db、.pyd拷贝到项目对应路径并把所有sys._MEIPASS引用改成项目绝对路径。.pyd依赖没法转成 Python 源码只能作为二进制依赖原样保留只要项目里能在对应路径找到并 import 成功就不影响主逻辑的阅读和单测。6. 验证还原结果把“看起来像源码”变成“跑得起来的工程”反编译完成不是终点代码能不能跑才是还原质量的真标准。我会按三步走来做验证而不只是盲目信赖反编译工具的“成功”输出。第一步是语法级验证把所有还原出来的.py文件批量跑一遍py_compile确认没有语法错误。这一步能快速定位 pycdc 输出里那些手滑生成的断裂语句。第二步是运行时验证从入口脚本开始用python -m trace --trace main.py跑一次最小流程看哪些 import 失败、哪些 API 调用因为参数格式跟还原出来的代码对不上而报错。第三步是逻辑比对找到 exe 在某个固定输入下的行为比如爬虫工具的请求 URL、命令行工具的配置文件输出让还原项目复现同样的行为并对比输出一致性。# 用 py_compile 批量验证反编译文件 import pathlib import py_compile for py_file in pathlib.Path(./restored).rglob(*.py): try: py_compile.compile(str(py_file), doraiseTrue) print(fOK: {py_file}) except py_compile.PyCompileError as exc: print(fFAIL: {py_file} - {exc})逻辑和参数说明doraiseTrue让编译错误直接抛出而不是只打印告警这样才能在批量模式里把每个坏文件揪出来。遇到失败的文件先检查是不是 pycdc 对某段控制流还原失败再人工修复对应行不要试图用脚本自动跳过因为那会让模块之间的函数签名全部错位。整个过程做完如果项目能跑通入口脚本并复现 exe 的核心行为那么“PyInstaller 打包的 exe 文件快速还原为 Python 源码脚本工具”这条路就算真正走到了头。我自己做过一次最头疼的还原是一个混淆过变量名、又把业务逻辑全写进生成器表达式里的工具pycdc 还原完三分之一的函数主体都是...。最后我用了最笨的办法把每个...对应的字节码用dis逐条读硬是花了两个晚上把核心逻辑拼了回来。那次之后我养成了一个习惯任何要交付的 Python 工具我都会把源码连同打包用的 spec 文件一起归档进 Git 仓库因为 exe 反编译能给你一个“能用”的代码给不了你“可维护”的工程。这个方向本身非常值得做尤其适合应急交接和安全审计但做完之后你会发现事先管好源码才是最好的后悔药。希望帮到你。本文还有配套的精品资源点击获取