简介这是一款面向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 和三件套可执行文件、依赖目录、CArchivePyInstaller 打包不是把你的 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 里读出 PYZPython 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, rC:\Users\Public\Temp), _MEI*) for path in sorted(glob.glob(pattern), keyos.path.getmtime, reverseTrue): # 只关心正在运行的 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 size3.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.76f 0d 0d 0a是 Python 3.8a7 0d 0d 0a是 Python 3.12。这个魔数直接决定你能不能反编译如果反编译工具不支持对应的 Python 版本强行跑只会报ValueError: bad marshal data或者干脆内存错乱。Python 版本pyc 魔数header 前 4 字节推荐反编译工具2.703 f3 0d 0auncompyle2 或 uncompyle63.60d 0d 0d 0auncompyle63.7 / 3.842 0d 0d 0a/55 0d 0d 0apycdcuncompyle6 对 3.7 支持极差3.961 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 一把梭哈反编译遇到错误就卡住。我习惯先用 pycdaspyc 反汇编器把 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_sipTrue): 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_okTrue) 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 还在”的亏我强烈建议把这段逻辑集成到你自己的打包脚本里。希望帮到你。本文还有配套的精品资源点击获取