简介:面向需要还原PyInstaller打包程序源码的开发者与安全分析人员,这款工具包专门解决从exe到py的逆向难题。整套流程自动完成两步核心操作:首先使用内置的pyinstxtractor脚本从可执行文件中提取pyc字节码,随后调用uncompyle6将字节码反编译为可读的Python源码。需要特别注意的是,本地Python解释器版本必须与目标exe打包时所用版本保持一致,否则会因字节码格式不兼容而导致提取或反编译失败,同时该工具不支持经过混淆或加壳处理的文件,使用前建议先确认目标程序基本信息。压缩包体积仅9KB,包含6个文件,其中两个py脚本分别承担主流程与解包功能,另有txt依赖说明、Markdown说明文档以及gitignore和inscode配置文件,整体开箱即用,无需额外安装第三方依赖。命令行输入python exe2py.py index.exe即可一键完成还原,已有98人学习使用,适用于开发调试、代码审计以及Python打包机制研究,能帮助读者快速获得目标程序的源码框架与关键实现逻辑。 做逆向和应急响应这几年,我经常被同事和同行问到一个问题:手头只有一个PyInstaller打包好的exe,里面跑的是什么业务逻辑,能不能把Python源码给还原出来?说实话,这个需求在安全分析、老项目维护、丢失源码恢复这些场景里非常常见。很多团队辛辛苦苦写完的Python程序用PyInstaller做成了exe交付,结果源码盘坏了、离职同事把仓库清空了,只剩一个二进制的exe。还有些时候,你拿到一个第三方工具,想看看它调了什么接口、做了哪些操作,也需要把exe拆开。
这里先说结论:PyInstaller打包的exe完全有办法还原出Python源码级别的脚本,而且整个流程走熟了之后,单文件十分钟左右就能拆完。问题在于网上讲这个事儿的资料非常零散,有的只讲了一半,有的直接甩一个工具链接让你自己猜。我这次把完整链条整理出来,从原理、工具、实操到踩坑,一次性讲透。
这个内容适合谁看?两类人。一类是自己打包过Python程序,因各种原因丢失了源码,急需从exe里捞东西的开发者;另一类是做安全分析、恶意脚本溯源的安全工程师。两种诉求不同,但操作路径是一样的。我把两类场景都覆盖到,你按需参考就行。
1. PyInstaller打包与还原的基本原理
1.1 PyInstaller打包出来的exe到底长什么样
很多人以为PyInstaller是把Python源码直接编译成本地机器码,跟C/C++编译出来的exe一样。这个认知要从根上修正——PyInstaller做的事情其实是“打包+自解压”,不是真正的编译。
它的核心思路是把三样东西塞进一个exe里:Python解释器、你用到的第三方库的字节码文件、入口脚本本身的字节码文件。exe启动时,先把这些内容释放到内存或临时目录,然后调用内置的Python解释器去执行。所以PyInstaller的exe本质上是一个自解压包,里面装的是Python生态的东西,而不是机器码。
这个特性决定了还原源码的可行性。既然exe里装的是字节码(pyc),而Python字节码有成熟的反编译工具链,那么我们只要能把这个包拆开,把pyc拿出来,再反编译成py,源码基本就回来了。
PyInstaller从5.x版本开始,打包格式经历了多次调整,但核心结构一直没变。exe文件主体是一个CArchive归档文件,里面包含了Python字节码、动态链接库、配置文件等。CArchive有一个结构化的头部信息,记录了每个文件在归档中的偏移量和大小。只要知道这个格式,就可以写脚本把里面的内容逐一提取出来。网上流传最广的解包工具pyinstxtractor.py,本质就是干了这么一件事。
1.2 为什么“还原源码”实际是“还原字节码”
既然我们拿到的不是编译产物而是字节码,那还原的精度取决于Python字节码反编译工具的成熟度。Python的老版本2.6、2.7,以及3.6、3.7、3.8这几个版本,字节码反编译非常成熟,很多情况下能还原出近乎百分百等价的源码,甚至连注释之外的逻辑都能恢复。
但Python 3.9之后,字节码的指令集和编译策略出现了变化,尤其是3.11引入了更激进的字节码优化,很多反编译工具会失手。比如某些复杂的推导式、条件表达式会被编译成难以还原的形式。这时候你反编译出来的代码可能逻辑正确但格式很怪,个别地方需要手工修正。
再补充一个关键认知:PyInstaller打包时会对pyc文件的头部做特殊处理,导致你从exe里提取出来的pyc并不是一个标准pyc文件。标准pyc文件头部有16字节的magic number、时间戳和文件大小信息,而PyInstaller提取出来的pyc头是缺失或篡改的,直接丢给反编译工具大概率报错。所以实操时第一件事就是修复pyc头,这一步不做,后面全白搭。
2. 工具选型与准备:实战前的三件套
2.1 pyinstxtractor:解包的第一把钥匙
解包工具方面,我首推pyinstxtractor.py。这个脚本是国外安全研究员写的,单文件,纯Python实现,不需要安装额外依赖。它支持PyInstaller 2.1到6.x的大部分版本,目前实测下来最新的6.10也能正常解包,后续如果PyInstaller继续改格式,这个脚本社区也在持续更新。
用法非常简单,把脚本丢到exe同目录,终端执行:
python pyinstxtractor.py your_program.exe执行完后,目录下会出现一个your_program.exe_extracted文件夹,里面就是解包出来的所有内容。核心的几个东西分别是:
PYZ-00.pyz:存放所有第三方库的Python字节码归档文件。base_library.zip:存放标准库的字节码。- 入口脚本对应的pyc文件(通常和你的主脚本同名,如
main.pyc)。 struct、pyimod01_os_path.pyc等PyInstaller自身的辅助模块。
这里要提醒一句:解包工具的Python版本不需要和exe内部的Python版本一致,工具本身用Python 3.10运行也能解出Python 3.8打包的exe,因为解包是二进制层面的格式解析,不涉及字节码反编译。
2.2 decompyle3 / uncompyle6 / pycdc:反编译三选一
拿到pyc之后,反编译工具的选择很关键,选错了你会发现代码逻辑完全对不上。我这些年用下来,总结如下:
- uncompyle6:老牌工具,支持Python 2.4到3.8。Python 3.8及以下的优先使用这个,还原度最高,代码风格最接近原始源码。但项目已经停止维护,遇到3.9以上版本直接放弃。
- decompyle3:uncompyle6的分支,重点优化了Python 3.7和3.8的反编译质量,处理一些异常复杂的控制流比uncompyle6表现更好。
- pycdc:用C++写的,支持到Python 3.11+。用它处理新版本打包的exe,有时能出结果,但代码风格比较乱,变量名、缩进经常不对,需要人工整理。
选择策略我直接给结论:先看exe打包时用的Python版本(解包后的struct文件里能看到),3.8及以下用uncompyle6或decompyle3,3.9以上用pycdc,并且做好手工修正的心理准备。
2.3 环境准备的几个坑
我前几次实操时踩过环境坑,这里提一下。解包和反编译建议用独立的虚拟环境,尤其不要和项目本身的Python环境混在一起,避免pyc文件解析时被干扰。
如果你用的是Windows,那么直接装一个Python 3.8就行,uncompyle6在Windows下运行稳定。如果你用的是macOS或Linux,反编译工具跑得更顺,尤其是pycdc需要编译,Linux下编译更便捷。
有的工具安装时会遇到依赖冲突,比如uncompyle6依赖的spark-parser版本和decompyle3冲突。解决办法是分两个虚拟环境,一个装uncompyle6,一个装decompyle3,别让它们共存。这不是洁癖,是真的会互相污染环境导致两个工具都跑不起来。
另外,反编译工具本身输出到终端时,如果有编码问题,记得把终端的编码设为UTF-8,否则中文注释会被打成乱码。
3. 完整实操流程:从exe到py脚本
3.1 第一步:解包exe,拿到pyc文件
实操开始。先建一个工作目录,把exe和pyinstxtractor.py放进去:
mkdir restore_source cd restore_source python pyinstxtractor.py demo.exe正常情况下的输出大概长这样:
[+] Processing demo.exe [+] Pyinstaller version: 2.1+ [+] Python version: 3.8.5 [+] Length of package: 21578932 bytes [+] Found 68 files in CArchive [+] Starting extraction... [+] Successfully extracted to: demo.exe_extracted这里注意看两处。一是PyInstaller的版本,二是Python的版本。这两个信息决定了你后续操作的工具选择。
进入解包目录后,先用文件管理器或者命令行看一眼里面有什么:
cd demo.exe_extracted ls -la你会看到很多文件,有struct、pyi-*.pyc之类的PyInstaller辅助文件,有PYZ-00.pyz,还有一堆.dll和.pyd文件。真正的主角是入口脚本的pyc,名字通常跟exe同名。比如exe叫demo.exe,那入口脚本就是demo.pyc。
如果有多个pyc看起来都像入口脚本,那说明程序可能有多个模块文件,每个模块对应一个pyc,入口脚本是主模块,其余是依赖模块。判断入口脚本的方法是看哪个pyc的导入关系最顶层,或者在解包信息里看运行入口。
3.2 第二步:识别入口脚本与依赖
这一步不能跳过。很多新手直接拿所有pyc去反编译,结果反编译出一堆工具库的代码,真正的业务逻辑反而没找到。原因就是没分清入口脚本和依赖库。
入口脚本的特征非常明显,它是整个exe运行时最早执行的Python文件,通常也是源码里你写的那个主文件。其他业务模块的pyc文件名和源码里的模块名一一对应。比如你的项目结构是main.py、utils.py、config.py,那解包目录下就有main.pyc、utils.pyc、config.pyc。
第三方库的字节码都被压进了PYZ-00.pyz里面,不会直接以pyc形式出现在目录下。所以解包目录下直接显示的pyc文件,基本就是你自己写的源码模块,数量不多,一眼就能认出来。
判断入口脚本还有一个技术手段:看pyc的__file__属性。用十六进制编辑器打开pyc,搜索源码文件路径字符串,比如/home/user/project/main.py这类路径。PyInstaller在编译时会把源码路径写入字节码的co_filename字段,这个字段能在十六进制内容里直接搜到。找到的那个pyc大概率就是入口脚本。
不过如果打包时用了--clean或者路径被改写过,这个字段可能被清掉或替换成相对路径,那就只能靠文件大小和模块名来判断了。
3.3 第三步:反编译pyc为py源码
拿到入口脚本的pyc之后,先看它的文件头。标准pyc文件头是4字节magic number加4字节时间戳再加4字节文件大小。但PyInstaller提取出来的pyc文件头被修改过,通常是跳过了前8个字节,相当于magic被抹掉了。
修复方法是手动把python版本对应的magic number填充进前4个字节。这里给一个实用列表:
| Python版本 | 魔数(十六进制小端) |
|---|---|
| 3.6 | 0x0d0a0d03 |
| 3.7 | 0x420d0d03 |
| 3.8 | 0x550d0d03 |
| 3.9 | 0x610d0d03 |
| 3.10 | 0xa70d0d03 |
| 3.11 | 0xb70d0d03 |
这个魔数可以用一行命令生成,比如Python 3.8环境下:
import importlib.util print(importlib.util.MAGIC_NUMBER.hex())输出是550d0d03,再以小端序写入pyc文件头。修复头之后,用uncompyle6反编译:
uncompyle6 main.pyc > main.py如果反编译成功,你会得到一个结构完整的Python源码文件。我实测过,Python 3.8环境下打包的简单脚本,反编译还原度超过95%,函数名、变量名、控制流、字符串全部保留。
3.4 第四步:对关键依赖库特殊处理
业务模块的pyc反编译完了,但程序运行依赖的第三方库代码也需要还原。这些库的字节码在PYZ-00.pyz里面,不能直接反编译,需要先解包pyz。
pyinstxtractor解包时已经顺手把PYZ里的内容提取到PYZ-00.pyz_extracted目录了,里面是一堆pyc文件,文件名是模块的路径名,比如requests/models.pyc、flask/app.pyc。这些pyc同样存在头缺失的问题,需要先修复再反编译。
有些场景你不需要还原所有第三方库的源码。比如你只是想分析业务逻辑,那库代码完全可以跳过,直接看入口脚本和业务模块就行。但如果你想完整还原整个项目的可运行源码,那就得把pyz里的所有pyc都反编译出来,按原本的目录结构放好。
还有个特例需要注意:有些模块是C扩展编译的,比如_sqlite3.pyd这类,它们的文件头不是pyc格式,反编译工具处理不了。这类模块直接跳过,不影响业务逻辑分析。
4. 常见问题与排查技巧实录
4.1 “magic number不对”怎么办
这是出现频率最高的报错。反编译时工具提示ImportError: bad magic number,说明pyc头没有修复成功或者修复错了。
排查思路很简单:确认exe用的Python版本,确认你写入的魔数是否匹配。有时候exe打包时用的Python是官方发行版,有时是conda环境或Embeddable Python,魔数都一样,问题不大。但如果打包工具是Nuitka或cx_Freeze,那情况就不同了,那两种工具打包出来的不是标准pyc,这篇文章的方法不适用。
实操里一个比较隐蔽的坑是:pyc文件里除了头部16字节,后面还会跟一个可选的长型marshal头,Python 3.8之后这个头的长度是动态的。如果你在修复头部时把时间戳和大小信息补错了,会导致反编译工具偏移错位,报一些奇怪的解析错误。稳妥做法是用十六进制工具打开pyc,对比同一Python版本正常编译出来的pyc文件,手工把头部逐字节对齐。
4.2 反编译出来乱码或带__pycache__的脚本
这种情况通常是Python 3.9以上的exe。反编译工具会输出部分代码,但有些语句丢失或解析失败,变成...占位符或# ...注释。
原因在于Python 3.9以后,某些控制流的字节码指令组合是工具没见过的,尤其是协程、异常处理嵌套、带条件的列表推导。解决办法是混合策略:先用pycdc反编译一遍,再用uncompyle6(如果版本支持)反编译一遍,两份结果对照着看。pycdc失败的地方uncompyle6可能成功,反之亦然。
还有个办法是进入反编译之后的手工修复。比如反编译结果里出现一行# 等价于的说明,后面跟着不完整表达式,你需要根据上下文把逻辑补全。这种修复工作量取决于代码复杂度,简单的逻辑几分钟搞定,复杂的可能得花一小时。
4.3 遇到pyimod01、pyimod02等模块
解包目录里会出现pyimod01_os_path.pyc、pyimod02_pyarchive.pyc这类文件,这是PyInstaller自有的引导模块,不是你的业务代码,直接忽略即可。反编译它们没有意义,反而会浪费时间。
不过有例外。某些PyInstaller的新版本会把这些引导模块以保护模式打包,如果解包时这部分文件损坏或者缺失,exe本身运行时也会报错。但这种情况在还原源码的场景里不需要关注,因为我们不关心exe怎么引导,只关心业务逻辑。
4.4 打包的exe加壳/混淆了怎么办
如果exe被加壳了,比如套了UPX壳,pyinstxtractor会报错说找不到CArchive。这时候需要先脱壳。UPX壳可以直接用UPX命令脱:
upx -d demo.exe脱完壳再解包。如果加了商业壳或自定义壳,情况复杂,可能需要动态调试去关键内存区域dump解压后的数据。这个就超出常规还原的范畴了,属于恶意样本分析的领域,用到的时候再说。
还有一种情况是打包时用了PyInstaller的--key参数做字节码加密。这种情况下解包出来的pyc是加密过的,无法直接反编译。工具会看到pyminiboot或toc相关内容,但pyc数据是密文。遇到这种情况,常规手段失效,只能靠运行时hook内存或者分析解密逻辑来还原,难度直接翻倍。如果你只是普通使用场景,基本不会遇到这种强对抗的打包方式。
4.5 一个小技巧:用TOC信息辅助定位入口脚本
解包目录下会有一个toc文件或者PYZ-00.toc文件,里面记录了打包时的模块路径和文件映射关系。这个文件是还原源码时的绝佳辅助。
比如你想知道你的业务模块原本在源码工程里的相对路径,直接查toc文件就行。它会把每个模块在源码里的导入路径写得清清楚楚。如果程序用了相对复杂的包结构,比如package_a/module_b.py这种层级,toc文件里能看到完整路径,方便你重建目录结构。
用我自己的经验说,有些大项目打包出来的exe解包后有几十个pyc,这时候没有toc文件来理清晰依赖关系,只靠文件名瞎猜会非常痛苦。
5. 我的实际体会与额外建议
用这套流程做源码还原,成败关键不在工具,而在细节判断。尤其是刚接触的人,拿到pyc之后不检查头就直接丢给反编译工具,大概率会碰壁。我现在的标准流程是:解包、检查Python版本、修复pyc头、确认入口脚本、反编译、验证逻辑完整性。每一步都有明确的检查点,全套走下来基本不出错。
另外一个值得说的点:这套还原方法理论上也可以用于安全研究、软件分析,但请务必遵守相关法律法规,只对自己拥有或获得明确授权的软件进行操作。
最后分享一个小技巧:如果你想验证反编译出来的源码是否正确,一个高效的办法是拿还原后的py文件直接用同版本的Python编译一下,看能不能生成字节码。如果编译报错,说明反编译结果有语法问题;如果能编译通过,再运行一遍对比行为,基本就能确认代码的可恢复性了。这个方法不需要原始源码,就能给你一个客观的还原质量评估。希望这次整理的完整流程,能帮你省下那些我当年踩坑浪费的时间。
本文还有配套的精品资源,点击获取