pyc 能还原回源码吗:pycdc、pycdas 和加密的 pye
拿到一个只有 .pyc 的 Python 程序,想看源码,常用的工具是 pycdc 和 pycdas。pycdas 把字节码翻成一条条指令,基本不会出错;pycdc 想直接还原出源码,却经常失败或者给出错的结果。有人为了不让别人反编译,会用 pyconcrete 把 .pyc 加密成 .pye。
这篇从 .pyc 里到底装了什么说起,解释反编译为什么难、出错了怎么发现,再看 .pye 这把锁是怎么上的、能挡住谁。文中的结果是我用同一份 50 行左右的样本程序,分别在 CPython 3.8、3.10、3.12、3.13 上实际跑出来的。
1. .pyc 里装的是什么
Python 运行一个 .py 文件,要先把它编译成字节码:
.pyc 文件就是一个 16 字节的头,后面跟着序列化的 code object。头的前 4 个字节叫 magic,标明这是哪个 Python 版本编出来的,比如 3.12 是 cb 0d 0d 0a,3.13 是 f3 0d 0d 0a。反编译工具先读这 4 个字节,再决定用哪个版本的指令表去解码后面的内容。
code object 里留下了这些东西:
- 字节码指令本身;
- 常量(数字、字符串、嵌套函数);
- 用到的全局名、属性名,以及参数名和局部变量名;
- 参数个数、是不是生成器之类的标志;
- 每条指令对应源码第几行。
变量名都在,所以反编译出来的代码不会变成 var1、var2。但注释、空行、缩进风格、括号怎么写,这些在编译时就扔掉了,.pyc 里没有。
2. 为什么反汇编容易,反编译难
2.1 编译是多对一的
我让同一个解释器编译几对不同的源码,比较生成的字节码:
| 源码 A | 源码 B | 字节码(3.8 / 3.12) |
|---|---|---|
x = 24 * 60 * 60 |
x = 86400 |
相同 / 相同 |
x = a + b # 求和 |
x=a+b |
相同 / 相同 |
if a and b: f() |
if a: 里再套 if b: f() |
相同 / 相同 |
while True: |
while 1: |
相同 / 相同 |
x = 1 if a else 2 |
if a: x = 1 else: x = 2 |
不同 / 相同 |
前四行,两种写法编出来完全一样。只拿到字节码的人,不可能知道作者写的是 24 * 60 * 60 还是 86400。最后一行更有意思:3.8 还能分清三元表达式和 if/else,到了 3.12 编译器多做了一步优化,两者就一样了。
所以反编译做不到"还原作者写的那份源码",它能做的是:给定一段字节码,找一段源码,让它编译出来和这段字节码完全一样。找到的这段不一定是原文,但它和原文在 Python 看来没有区别。
这个定义也给出了检验反编译结果的办法:把结果重新编译,和原来的字节码逐条比对。第 4 节会用到。
2.2 两个工具做的是两种事
pycdas 是反汇编器。字节码里每个操作码对应一条指令,查表就能翻译,比如 7c 00 就是 LOAD_FAST 0(取第 0 个局部变量)。只要指令表对,结果就对。
pycdc 是反编译器,要做的事多得多。CPython 的虚拟机是基于栈的:指令从栈上取操作数,再把结果压回去。pycdc 模拟这个过程,但栈上放的不是真实的值,而是表达式。以 return round(amount * discount * (1 + RATE), 2) 为例:
| 指令 | pycdc 脑子里的栈(右边是栈顶) |
|---|---|
LOAD_GLOBAL round |
round |
LOAD_FAST amount |
round, amount |
LOAD_FAST discount |
round, amount, discount |
BINARY_OP * |
round, amount * discount |
LOAD_CONST 1、LOAD_GLOBAL RATE、BINARY_OP + |
round, amount * discount, 1 + RATE |
BINARY_OP * |
round, amount * discount * (1 + RATE) |
LOAD_CONST 2、CALL 2 |
round(…, 2) |
RETURN_VALUE |
输出 return round(…, 2) |
没有跳转的代码,这样一步步推下去基本不会错。
2.3 难在跳转
字节码里没有 if、for、try,只有"条件成立就跳到某处"和"直接跳到某处"。pycdc 得看跳转的形状,猜出原来是什么语句:
- 条件跳转往前跳,分支末尾再往前跳一下,大概是
if/else; - 两个分支各自往栈上压一个值,汇合以后才用掉,大概是三元表达式;
- 循环体末尾往回跳,是
for或while。
这些规则是从编译器的习惯里总结出来的,编译器一改习惯,规则就失效。比如从 3.9 开始,finally 里的代码会被复制到每一条退出路径上(正常返回一份、except 一份、异常往外抛一份),反编译器得先认出"这三段是同一份代码的副本",才能合回一个 finally。
CPython 几乎每个版本都会改字节码:3.11 换了整套异常处理机制,3.12 把列表推导式内联进了函数,3.13 改了函数的创建方式。每改一次,pycdc 的规则就要补一批。它总是在追版本,这是它不稳定的根本原因。
3. 实际跑一遍
样本程序里有默认参数、三元表达式、for 加 continue、try/except/finally、类、列表推导和生成器。四个版本的结果:
| 版本 | pycdas | pycdc |
|---|---|---|
| 3.8 | 成功 | 退出码 0,但 finally 没还原,输出编译不过 |
| 3.10 | 成功 | 退出码 1,后半截输出直接没了 |
| 3.12 | 成功 | 退出码 0,推导式和生成器都放弃了,输出编译不过 |
| 3.13 | 成功 | 退出码 0,输出能编译,但只剩 3 行,所有函数都没了 |
pycdas 四个版本全部成功,pycdc 四个版本全有问题,而且四次里有三次退出码是 0。靠退出码判断成败是不行的。
更麻烦的是"看起来对"的错误。def price(amount, vip=False) 在 3.8、3.10、3.12 下都被还原成了:
def price(amount, vip = (False,)):
看着只多了个括号,实际上默认值变成了一个非空元组,而非空元组在判断里是真。拿这份代码去跑,不传 vip 的普通用户也全按 VIP 打了九折:原本 price(100) 是 108.0,现在是 97.2。代码能编译,也不报错。
有时候问题不在信息丢了,而在 pycdc 没认出来。3.8 的三元表达式被还原成了 0.9 if None else 1,条件 vip 丢了;但 pycdas 的输出里 LOAD_FAST vip 清清楚楚,同样的写法在 3.12 也还原对了。pycdas 的用处就在这里:它能告诉你是"信息本来就没有",还是"工具没认出来"。
pycdas 也不是绝对可靠。拿它和 Python 自带的 dis 对照 3.13 的输出,有一处跳转目标算错了,还有一个常量 1.0 被显示成了 1。拿不准的时候,用同版本 Python 的 dis 当标准答案。
4. 怎么判断结果对不对,卡住了怎么办
按第 2 节的定义,反编译结果对不对,只有一个可靠的判断方法:把它用同版本的 Python 重新编译,和原来的字节码逐条比。几种检查方式的效果差别很大:
| 检查方式 | 能发现 | 发现不了 |
|---|---|---|
| 看退出码 | 几乎什么都发现不了 | |
看输出里有没有 WARNING、Unsupported |
明显放弃的函数 | 看起来对的错误 |
| 重新编译,跑几个用例 | 语法错误、明显的行为错误 | 用例没覆盖到的分支 |
| 逐个函数比对字节码 | 条件丢失、常量类型不对 | 默认值这类存在函数外面的东西,要单独比 |
比如 3.12 的 price,肉眼看完全正确,逐条比对才发现常量 1.0 被还原成了 1。
pycdc 卡住时,可以这样处理:
拆函数这一步很管用。3.13 的 pycdc 是在模块顶层第一个 def 那里卡住的,后面的内容全没了;但单个函数内部未必有那条它不认识的指令。把每个函数的 code object 拆出来,配上原文件的 16 字节头,伪装成独立的 .pyc 再交给 pycdc。这样一来,3.13 下 Cart 的两个方法能完整还原了,parse 也能还原个大概(finally 仍然被拆成三份),不再是"只剩 3 行"。另外几个函数里有 pycdc 不认识的新指令,拆开也救不回来。
剩下的函数只能对着反汇编手工还原。比如生成器 countdown,pycdc 四个版本都没还原对,但它的反汇编只有十几行,能看出 n > 0 出现了两次(编译器把 while 的条件复制了一份放到循环末尾),认出这个套路就能写回 while n > 0: yield n; n -= 1。在 3.8、3.12、3.13 上比对字节码,都完全一致。
5. .pye:给 .pyc 加一把锁
上面这些都有个前提:.pyc 是明文的,谁拿到都能读。于是有人想,把 .pyc 加密,运行时再解密,不就没法反编译了吗?pyconcrete 就是这么做的,加密后的文件叫 .pye。下面说的是 pyconcrete 1.3.0。
5.1 从需求推出它长什么样
不看代码,只从要解决的问题出发,就能推出它需要哪些零件:
- 要把
.pyc变成密文,得有个加密工具。对应pyecli命令。 - Python 不认识密文,
import的时候得先解密。所以要往导入系统里挂一个钩子。 - 解密需要密钥,而程序是要发给别人运行的,所以密钥只能跟着程序一起发出去。pyconcrete 的做法是安装时用你给的口令生成密钥,编进 C 代码里。
- 密钥编在二进制里,每个人的都不一样,所以它没法提供预编译好的安装包,每次安装都得在本机现编 C 代码。没有 C 编译器就装不上。
使用方式有两种:
- exe 模式(默认,推荐):生成一个
pyconcrete可执行文件,用pyconcrete main.pye启动,连入口脚本都可以加密。 - lib 模式:生成一个 Python 扩展,入口
main.py必须是明文,第一行import pyconcrete装上钩子。作者自己在 README 里说这种模式 "not safe enough",原因在 5.4 节。
5.2 .pye 是怎么做出来的
- 密钥:口令做一次 MD5,得到 16 字节的 AES-128 密钥,再做一层简单的异或混淆后编进二进制,免得在十六进制编辑器里一眼就看到。
- 加密:先正常编译出
.pyc,然后把整个文件,包括 16 字节的头,按 16 字节一块逐块用 AES 加密,最后一块补齐。
整个 .pyc 都被加密了,所以解密回来的就是一个字节不差的原始 .pyc。我用正确口令解密后比对 SHA256,完全一致;交给 pycdc,输出和直接反编译那个 .pyc 一模一样,连 vip = (False,) 那个错误都原样出现。
直接把 .pye 交给 pycdc 或 pycdas,只会得到 Bad MAGIC!:它们第一步读 magic,而加密后的开头是一堆看不出规律的字节。
扩展名可以随便改,认 .pye 要看内容:开头不是 xx xx 0d 0a 这样的 magic,文件大小正好是 16 的倍数,字节分布接近随机。
5.3 运行时在哪一步解密
Python 导入一个模块分两段:先查找,只看文件名,决定由谁来加载;再加载,读文件内容,得到 code object,然后执行。
.pye 的内容只在需要 code object 时才用得上,所以解密只能放在加载段,而且越晚越好。pyconcrete 正好是这样做的:它的加载器只改了"取代码"这一步,读出密文,解密,检查 magic 和当前 Python 版本是否一致,然后反序列化成 code object 交给 Python 执行。
把解密放在别的位置都有问题:启动时一次性全解开,没用到的模块也解了,明文要么占着内存要么写到磁盘上;放在查找阶段,IDE 补全、包扫描这类只想知道"有没有这个模块"的操作也会触发解密;解密后写成 .pyc 缓存,就等于没加密。
这样做的结果是:没被导入的 .pye 永远不会被解密;解密出来的明文只在内存里存在一下,马上变成 code object,__pycache__ 里也不会出现明文 .pyc。
使用时要注意几点:
- 口令错了,报的是版本不匹配。 加密格式里没有完整性校验,口令错了也能"解密"出一堆乱码,直到检查 magic 时才发现不对,报错信息会是
Python version doesn't match with magic: python(3531) != pye(57570)。看到一个根本不存在的版本号,先怀疑口令。 - 只能在打包时的 Python 版本上运行。 里面装的是字节码,字节码绑定版本,3.12 打的包拿 3.13 跑会报版本不匹配,这次是真的不匹配。
- 报错时文件名、行号、函数名都还在。 它们本来就存在 code object 里,加密不会动,只是显示不出源码行。打包机器上的源码路径也会跟着发出去。
- lib 模式下
import pyconcrete要放在最前面。 钩子装上之前,Python 不认识.pye。
5.4 这把锁有多牢
回到最朴素的事实:程序要运行,CPU 最终执行的是解密后的字节码,所以密钥和解密代码必须跟程序一起交到用户手里。能运行,就一定能解密。pyconcrete 的作者在 README 最后也写了:
there is always a way to decrypt .pye files, but pyconcrete just make it harder.
具体的弱点:
- lib 模式下,解密函数是公开的。 同一个 Python 进程里的任何代码都能调用它。我写了三行脚本
pyconcrete.decrypt_buffer(...),直接把.pye解回了原始.pyc,不需要知道口令,也不用逆向任何二进制。exe 模式把这个入口收进了启动器,外人没有现成的地方插代码,但也只是门关小了,没关死。 - 密钥只做了简单的混淆。 防得住在十六进制编辑器里扫一眼,防不住认真的逆向分析。
- 口令就是全部的安全性。 密钥只是口令的一次 MD5,没有加盐,弱口令可以离线猜;
.pyc头的前几个字节又是公开的,每猜一个口令都能马上验证对不对。别人用同一个口令装一遍 pyconcrete,也能解你的文件。 - 加密方式比较老。 每块独立加密,相同的明文块会得到相同的密文块,同一个文件每次加密结果都一样。
- 运行起来以后,内存里就是普通的 code object。 第一次解密后,还原出来的密钥也一直留在进程内存里。能读进程内存的人,可以直接拿到密钥或明文。
和其他防反编译的办法放在一起比较:
| 做法 | 发出去的是什么 | pycdc 还能用吗 |
|---|---|---|
只发 .pyc |
明文字节码 | 能 |
.pye(pyconcrete) |
加密的 .pyc 加带密钥的加载器 |
解密以后能 |
| 字节码混淆 | 改写过的字节码加专用运行时 | 一般不能直接用 |
| Cython / Nuitka 编成原生代码 | 机器码 | 不能,已经没有 Python 字节码了 |
.pye 没有改变"字节码能被反编译"这件事,它是把 .pyc 锁进一个盒子,而钥匙就挂在盒子旁边。它能挡住的,是拿到了文件却拿不到能运行它的程序的人。
6. 小结
- 编译会丢信息,而且是多对一的,所以反编译只能找一段"编译结果相同"的源码,不可能找回原文。
- pycdas 是查表翻译,稳定;pycdc 要靠规则猜代码结构,规则随 Python 版本一直在变,所以经常失败。
- 退出码、肉眼、"能编译"都不能证明反编译结果是对的,要重新编译后逐条比对字节码。
- pycdc 卡住时,拆成单个函数再试;还不行就对着 pycdas 的输出手工还原,再比对确认。
.pye只是在文件外面加了一层 AES,解开就是原样的.pyc。只要程序能在别人机器上运行,解密需要的一切就都在别人手里。
最后说一句:反编译和解密只该用在你有权处理的代码上,比如自己丢了源码的项目、被授权分析的样本,或者用来学习 CPython 编译器。pycdc 是 GPLv3 许可,pyconcrete 是 Apache 2.0 许可。

浙公网安备 33010602011771号