简介:这是一款面向Python开发者和代码审计人员的轻量逆向工具,专门用于还原PyInstaller打包生成的Windows可执行文件,适合在拿到发布包却需要查看原始脚本逻辑时使用。工具集成了从可执行文件中提取字节码、再反编译回源码的两步处理,用户只需在命令行输入原始exe路径执行一条指令,无需安装任何第三方库,即可快速还原打包后的脚本代码。资源包共6个文件,核心为两个Python脚本,一个负责解包提取,一个负责流程调度;同时包含Markdown与文本说明文件及少量配置项,压缩后仅9KB。目前已有102人学习,可用于开发调试、代码审计、学习Python打包原理等场景;需要特别注意的是,本地Python解释器版本应与目标exe所用版本一致,否则可能因字节码格式不兼容导致还原失败,混淆或加壳后的exe也无法处理。
1. PyInstaller 生成的 exe 并不是黑匣子:pyc 还原路线能走通
如果你手里只有一个 PyInstaller 打包出来的 exe,想把它还原成可读的 Python 源码,先别急着认定这是逆向工程师才能干的活。PyInstaller 的打包机制决定了它必须把程序真实的 .pyc 字节码在运行时释放到临时目录,再由内置的 Python 解释器加载执行。只要抓住这个释放动作,就能把源码从产物里“捞”回来。整个过程涉及 exe 反编译、pyc 还原、魔数校验和字节码反汇编,不是 100% 还原,但能把主逻辑和大部分字符串捞出来,足够你恢复一个能跑的工程。这篇文章就按我实际拆过的路径讲:先摸清 PyInstaller 的产物结构,再讲怎么取 pyc、怎么反编译、哪些环节最容易翻车,最后给一个打包前的源码自备份思路。适合手里留着老 exe 但丢了源码的开发者,也适合做安全分析、想快速看别人工具实现逻辑的人。
2. PyInstaller 的产物结构:exe 只是一个壳,真正的 pyc 在临时目录里
2.1 打包后的 exe 和三件套:可执行文件、依赖目录、CArchive
PyInstaller 打包不是把你的 Python 源码直接翻译成机器码,而是把你的源码先编译成 .pyc 字节码,再用 zlib 压缩,最后连同 Python 解释器、动态链接库、依赖包一起塞进一个可执行文件。这个可执行文件在 Windows 上一般叫 xxx.exe,旁边还有一个_internal目录(PyInstaller 6.x 以后的默认布局),里面是base_library.zip、python312.dll、各种.pyd扩展模块和资源文件。
注意:PyInstaller 5.x 之前,依赖文件直接散在 exe 同级目录下;6.x 开始收进
_internal。不同版本还原时找临时目录的方式一样,但产物布局会影响你对“哪些是项目文件、哪些是依赖”的判断。
exe 本体里的 Python 字节码放在一个叫 CArchive 的结构里,CArchive 是 PyInstaller 自己的归档格式,里面存的是打包时收集到的所有 PYZ 条目,也就是你写的模块的 pyc 压缩块。bootloader 启动流程分三步:
- 从 CArchive 里读出 PYZ(Python Zip 归档)数据;
- 用 zlib 解压成 pyc 字节码;
- 把解压后的 pyc 释放到一个随机命名的临时目录,Windows 下通常是
C:\Users\你的用户名\AppData\Local\Temp\_MEIxxxxxx,其中 xxxxxx 是六位随机数字。
程序运行期间,解释器就从_MEIxxxxxx目录里加载模块,这也是为什么 PyInstaller 程序启动慢、杀毒软件容易报毒——它在运行时释放可执行代码到临时目录,这个行为和很多木马一致。
用 strings 工具扫一下 exe,能看到明显的标识:
strings 目标.exe | grep -iE "MEI|pyi|python3|PyInstaller" | head -n 20这段命令在 Linux 或 Windows 的 Git Bash 里都能跑。strings会提取 exe 里的 ASCII 和 Unicode 字符串,grep 过滤出MEI、pyi、python3这类关键词。输出里如果出现_MEIPASS,说明它确实是 PyInstaller 产物,而且你能从旁边的python312.dll之类字符串判断它内置的 Python 版本。这个信息决定后面选哪个反编译工具,版本判断错了基本白干。
2.2 为什么还原时要盯住临时目录而不是 exe 本身
很多人拿到 exe 第一反应是直接用反编译工具去拆 exe 文件本身,这个方向成功率极低。因为 CArchive 是 PyInstaller 自定义的压缩结构,不是标准的 PE 资源段,通用反编译器根本不认识。正确思路是“运行时截取”:你运行这个 exe,等它把 pyc 释放到临时目录,然后抢在程序退出前把整个_MEIxxxxxx目录复制一份。
听起来简单,实际上有两个要点:
_MEIxxxxxx目录名是随机的,每次启动都会变,没法提前写死路径;- 程序退出时 PyInstaller 会清理临时目录,只有“运行中”这个时间窗口能看到完整的 pyc 文件。
实用做法是做一个轮询脚本,不断扫描 Temp 目录,发现新的_MEI*文件夹就立刻复制走。在 Windows 上我一般用 Python 的glob配合psutil来做:
import glob import os import shutil import psutil import time def find_new_meipass(): pattern = os.path.join(os.environ.get("TEMP", r"C:\Users\Public\Temp"), "_MEI*") for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True): # 只关心正在运行的 PyInstaller 进程创建的目录 for proc in psutil.process_iter(["name", "pid"]): try: # Windows 下 exe 进程的工作集里会加载 _MEIPASS 路径 # 这里简单判断:目录修改时间在 60 秒内,且目录里有 python*.dll if time.time() - os.path.getmtime(path) < 60: if any(f.endswith((".dll", ".pyd")) for f in os.listdir(path)): return path except (psutil.AccessDenied, psutil.NoSuchProcess, OSError): continue return None target = find_new_meipass() if target: dest = os.path.join(os.getcwd(), "pyc_dump") shutil.copytree(target, os.path.join(dest, os.path.basename(target))) print(f"[+] 已复制临时目录: {target} -> {dest}") else: print("[!] 未发现新的 _MEI 目录,确认目标程序是否正在运行")这段代码的逻辑是:先扫%TEMP%下所有_MEI*目录,按修改时间倒序取最新的;再通过psutil确认有进程存活,并且目录里有.dll或.pyd文件就认定是有效的 PyInstaller 运行时目录,最后整个复制。注意pattern里的TEMP环境变量要用os.environ.get拿默认兜底,因为服务账户和普通用户的 Temp 路径可能不一样。复制动作要快,目标进程一退出临时目录就被删,这是抢时间窗口的活。
2.3 拿到 pyc 之后先看文件头:魔数决定一切
临时目录里会有一堆 pyc,文件名和模块路径对应,比如main.pyc、utils.cpython-312.pyc。第一步不是急着反编译,而是看 pyc 文件头。标准 pyc 头在 Python 3.6 及以下是 8 字节,3.7 以后变成 16 字节(4 字节魔数 + 4 字节 flags + 4 字节 timestamp + 4 字节 source size,3.7+ 额外多了 8 字节的 sip 哈希头)。用 hexdump 看:
xxd main.pyc | head -n 3正常输出会类似这样:
00000000: 6f 0d 0d 0a 00 00 00 00 00 00 00 00 00 00 00 00前四个字节6f 0d 0d 0a是 CPython 的 magic number,不同 Python 版本对应不同值。比如0d 0a 0d 0a是 Python 2.7,6f 0d 0d 0a是 Python 3.8,a7 0d 0d 0a是 Python 3.12。这个魔数直接决定你能不能反编译:如果反编译工具不支持对应的 Python 版本,强行跑只会报ValueError: bad marshal data或者干脆内存错乱。
| Python 版本 | pyc 魔数(header 前 4 字节) | 推荐反编译工具 |
|---|---|---|
| 2.7 | 03 f3 0d 0a | uncompyle2 或 uncompyle6 |
| 3.6 | 0d 0d 0d 0a | uncompyle6 |
| 3.7 / 3.8 | 42 0d 0d 0a/55 0d 0d 0a | pycdc(uncompyle6 对 3.7+ 支持极差) |
| 3.9+ | 61 0d 0d 0a以上 | pycdc / pycdas |
从 PyInstaller 6.x 默认内置 Python 3.11/3.12 来看,uncompyle6 基本可以直接放弃,重点放在 pycdc 这类基于 C++ 实现的反编译器上。记住一个判断标准:“release 年份接近 pyc 魔数的工具才靠谱”,这句话能帮你少走一半弯路。
3. 把 pyc 还原成 py:字节码反汇编和源码恢复的实操路线
3.1 先反汇编,再谈反编译:pycdas 能告诉你字节码里到底有什么
很多教程直接让你拿 pycdc 一把梭哈反编译,遇到错误就卡住。我习惯先用 pycdas(pyc 反汇编器)把 pyc 变成可读的字节码指令列表,这一步能确认 pyc 是否完整、有没有被 strip、函数边界在哪。
pycdas 是 Decompyle++ 项目里的工具,用法直接跟文件路径走:
pycdas.exe main.pyc > main_dis.asm输出的反汇编文件长这样:
# Method main # Code: LOAD_CONST 1 ('__file__') LOAD_CONST 2 ('main.py') ... LOAD_NAME 0 (print) LOAD_CONST 4 ('hello from packed exe') CALL_FUNCTION 1 POP_TOP LOAD_CONST 0 (None) RETURN_VALUE看反汇编的作用有三层:
- 确认 pyc 文件头的魔数是否正确,如果 pycdas 能把指令列出来,起码说明文件没坏;
- 看到
LOAD_CONST里的字符串常量,等于把源码里最重要的硬编码文案和路径都捞出来了; - 对 pycdc 反编译失败的模块,可以照着指令集手工重建逻辑,工作量比从零逆向小得多。
参数上要注意,pycdas 和 pycdc 都是命令行工具,不传任何选项直接跟 pyc 路径就行,输出默认打到 stdout。如果 pyc 路径里有中文或空格,Windows 下用cmd /c "pycdas.exe 路径"绕开编码问题。
3.2 pycdc 反编译主流程:能出代码,但不保证语法正确
确认 pyc 结构没问题之后,用 pycdc 做整体反编译:
pycdc.exe main.pyc -o main_restored.py-o指定输出文件,否则结果打到 stdout,长文件刷屏不好排查。反编译结果通常能还原出函数定义、控制流、变量赋值和大部分表达式,但有几个特征要提前有预期:
if/for/while结构在多数情况下能正确还原;- 推导式(列表推导、字典推导)偶尔被还原成等价循环,逻辑对但代码风格和原版不一样;
- 装饰器、async/await、f-string 这类语法,pycdc 还原效果不稳定,可能出现语法错误或语义偏差。
以我拆过的几个工具为例,pycdc 还原后的代码“能读懂、能跑通主流程”,但离“原始源码一模一样的可维护代码”还有差距。字符串常量、导入关系、函数名和参数名基本无损,这些才是还原的核心资产。
3.3 uncompyle6 只建议在 Python 3.7 及以下用
如果你的 pyc 魔数显示是 Python 3.7 或更早版本,可以试试 uncompyle6,它比 pycdc 在语法还原上更干净,能处理列表推导和生成器表达式:
uncompyle6 -o restored_dir main.pyc不加-o时结果打到 stdout,-o后跟输出目录,原文件同名输出。但注意 uncompyle6 项目已经处于半停滞状态,对 Python 3.8+ 的 pyc 基本报Unsupported Python version,这种情况下不要硬试,换 pycdc。
提示:还原出来的 py 文件如果语法报错,优先用
ast.parse检查能不能被 Python 解释器理解,而不是盯着 pycdc 的警告看。exe 反编译这种场景,“语义正确但语法需要修”是常态。
3.4 手动修复反编译结果的实用套路
pycdc 还原的文件遇到最高频的语法错误是f-string还原成普通字符串拼接失败,以及match语句(Python 3.10+)被还原成不存在的语法结构。我的处理流程是:
- 打开还原后的
.py文件,跑一遍python -m py_compile; - 根据报错行号定位到问题代码块;
- 对照 pycdas 反汇编里的字节码,把变量名和常量手动填补回去。
比如原始源码写的是:
msg = f"用户 {name} 的操作失败,错误码:{code}"pycdc 可能还原成:
msg = "用户 " + name + " 的操作失败,错误码:" + code这种可以手工合并成 f-string,语义等价。遇到match语句被还原成if链,那就照着 pycdas 里的MATCH_MAPPING、MATCH_KEYS指令重写,工作量不大但很费眼睛,我会把 pycdas 输出和 pycdc 输出并排放在 VSCode 里对照,用两个编辑器窗口同步滚动。
3.5 完整实操:从拿到 exe 到恢复出可读主文件
把前几节串成一个完整流程,假设手里有个sample_app.exe:
# 1. 确认是不是 PyInstaller 产物 strings sample_app.exe | grep -i "MEI\x00" # 2. 运行 exe 并抓取临时目录(前面给出的 find_new_meipass 脚本) python grab_meipass.py # 3. 列出 main 模块或者体积最大的 pyc(通常就是入口模块) ls pyc_dump/_MEI123456/ -la | sort -k5 -rn | head # 4. 查看 pyc 头确认 Python 版本 xxd pyc_dump/_MEI123456/main.pyc | head -n 1 # 5. 先用 pycdas 反汇编保底 pycdas.exe pyc_dump/_MEI123456/main.pyc > main_dis.asm # 6. 再用 pycdc 还原源码 pycdc.exe pyc_dump/_MEI123456/main.pyc -o main_restored.py # 7. 语法检查 + 手工修复 python -m py_compile main_restored.py这个流程里最容易出错的是第 3 步的“体积最大的 pyc 不一定是最重要的模块”,有些项目在打包时会带很大的第三方库 pyc,真实的业务模块反而小。判断入口模块的常见策略是看main.pyc、文件名字与 exe 名一致、或者 pyc 修改时间最晚的。用sort -k5 -rn按大小排只是初步筛选,具体还得看内容判断。
4. 避坑:exe 还原成 py 时最常见的五个翻车现场
4.1 还原出的 pyc 一上来就是bad marshal data
现象:用 pycdc 或 uncompyle6 打开 pyc,立刻报ValueError: bad marshal data (unknown type code)。
原因:PyInstaller 释放的 pyc 在 Python 3.7+ 版本文件头里多了 8 字节的 sip 哈希校验,很多工具没适配,把校验位当成 marshal 数据解析了。
解决:先把 pyc 文件头标准化,去掉 3.7+ 多出来的头部膨胀:
def strip_pyc_header(in_path, out_path, remove_sip=True): with open(in_path, "rb") as f: data = f.read() if remove_sip: # Python 3.7+ pyc 头部 16 字节,但反编译工具期望 12 字节 data = data[:4] + data[12:] with open(out_path, "wb") as f: f.write(data)这里data[:4]保留魔数,data[12:]直接把 timestamp、size 和 sip 标志全去掉,输出给旧工具用。注意:不是所有 pyc 都需要处理,先看头部长度,16 字节才需要,8 字节的头直接就能喂给老工具。判定方法在前面xxd输出里提过。
4.2 uncompyle6 对高版本 Python 支持为 0
现象:uncompyle6 跑 3.8+ 的 pyc,报ValueError: unsupported version或者直接不输出。
原因:uncompyle6 核心停在 Python 3.7/3.8 时代,后续 CPython 字节码变化它没有跟进。
解决:放弃 uncompyle6,改用 pycdc。如果 pycdc 也没有适配到某个 Python 3.11 的小版本,优先用 pycdas 反汇编人工分析。pycdc 的 GitHub 仓库通常比 PyPI 上的版本新,遇到适配问题就去仓库拉最新 release,别守着老版本等更新。
4.3 好不容易还原出 py,一运行就报语法错误
现象:pycdc 还原的代码能看,但python -m py_compile报SyntaxError,集中在带f前缀字符串或async函数的位置。
原因:pycdc 走的是 C++ 实现的“字节码到源码”翻译,对 Python 高版本语法支持不够,尤其 f-string 内部表达式、await嵌套在推导式里,它会翻译出不符合语法的代码。
解决:这类问题无法全局修复,只能按报错行号手工调整。实用技巧是先跑python -m py_compile拿到错误清单,再用 pycdas 对照字节码把常量拼接回来。我有一次处理一个 2000 行的还原文件,语法错误 23 处,全部是 f-string 相关,手工修补花了 40 分钟,但跑通了。
4.4 pyc 里没有你想要的资源和配置
现象:临时目录里找到的 pyc 都是纯代码,程序依赖的config.ini、图标icon.png、资源文件夹assets根本不在 pyc 里。
原因:PyInstaller 打包时,代码文件编译成 pyc 进了 archive,但资源文件是以 data 形式原样压缩存储的,不在 pyc 里,运行时sys._MEIPASS指向的目录才包含资源。
解决:如果你需要资源文件,要在 exe 运行时从sys._MEIPASS路径下直接复制。具体做法是找到 release 出来的完整_MEIxxxxxx目录,把里面的非.pyc、非.dll、非.pyd文件按原路径抄一遍。配置文件、图片、文本这些大多可以直接用,个别加密的自定义格式另说。
4.5 程序退出太快,来不及抓临时目录
现象:轮询脚本还没反应过来,exe 就启动完并退出了,_MEI目录被删干净。
原因:PyInstaller exe 的初始化快,退出时 cleanup 也快。短命令工具型 exe 从启动到退出不到 0.5 秒,轮询脚本的glob扫描加psutil判断根本跟不上。
解决:提前启动 exe 并让它挂起。常见做法是用管道让它等待输入,比如目标 exe 是命令行交互工具,运行cmd /c "sample_app.exe < my_input.txt",让输入文件内容足够大、处理足够慢。更可靠的做法是把 exe 塞进调试器暂停住,或者在 exe 启动瞬间用pause命令钩住进程:
python -c "import subprocess; p = subprocess.Popen(['sample_app.exe']); import time; time.sleep(3); p.terminate()"逻辑是:先启动进程,再睡 3 秒让临时目录稳定生成,然后 terminate 掉进程,防止退出时清理。注意terminate()时间要控制好,太晚程序可能自己跑完了,临时目录已经被回收;太早又可能还没创建_MEI目录。3 秒是我试过相对稳的值,具体程序可以调整。
5. 进阶:反过来的事——打包前给自己留一条源码恢复后路
如果你是自己打包的人,与其事后对着临时目录反编译,不如在工程里主动留一个“源码快照”功能。这个思路对团队协作尤其有用:运维手里十几个老工具 exe,源码在哪台构建机、哪个 commit 早丢了,这时候你能从 exe 里一键导出源码,比逆向省力得多。
做法是在入口脚本最前面加一段导出逻辑,让程序在启动时把自己所在的包内源码 dump 到一个隐藏目录:
import sys import os import shutil import importlib def snapshot_source(save_dir="~/.pysrc_backup"): expand_dir = os.path.expanduser(save_dir) os.makedirs(expand_dir, exist_ok=True) meipass = getattr(sys, "_MEIPASS", None) if not meipass: return for root, _, files in os.walk(meipass): for name in files: if name.endswith(".pyc"): src = os.path.join(root, name) rel = os.path.relpath(src, meipass) dst = os.path.join(expand_dir, rel + ".bak") shutil.copy2(src, dst) print(f"[info] source snapshot saved to {expand_dir}") if __name__ == "__main__": snapshot_source()sys._MEIPASS是 PyInstaller 运行时注入的变量,指向临时目录根,程序启动后它一定存在。打包前把这个函数调用放在 main 之前,exe 每次运行都会自动把自身的 pyc 备份到用户目录,文件名保留.pyc.bak后缀,后期需要还原时用 pycdc 处理即可。
这段逻辑的边界条件:
_MEIPASS不存在时(比如直接跑.py源文件),函数空转退出,不干扰开发环境;- 备份路径要放在用户目录而不是程序目录,避免“程序装在 Program Files 下没写权限”导致启动崩溃;
- pyc 备份越多越好,别只备份入口
main.pyc,会把模块依赖也留下,后面pycdc反编译单文件时更容易上下文完整。
我给团队的工具就这样做过一次。后来的某次事故里,构建服务器硬盘损坏,所有源码都没了,运维从存量 exe 的备份目录里把所有 pyc 都翻了出来,用 pycdc 反编译再加上手工修 f-string,最后把当时那一版工具恢复到了能编译通过的程度。从那以后我给自己定了个规矩:不管是给客户的交付包还是内部分发工具,打包机上永远同时留一份源码 zip 跟 exe 放一起,然后还要在 exe 里埋一条snapshot_source(),双保险才敢发出去。如果你也吃过“源码丢了、exe 还在”的亏,我强烈建议把这段逻辑集成到你自己的打包脚本里。希望帮到你。
本文还有配套的精品资源,点击获取