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 文件,要先把它编译成字节码:

%%{init: {"theme":"base","themeVariables":{"fontSize":"16px"},"flowchart":{"useMaxWidth":false}}}%% flowchart LR A["源码 .py"] --> B["语法树"] B --> C["字节码<br/>(code object)"] C --> D[".pyc 文件<br/>16 字节头 + 序列化数据"] D -. "pycdas:查表翻译" .-> G["指令清单"] D -. "pycdc:推测还原" .-> H["源码"] style G fill:#e6f4ea,stroke:#34a853 style H fill:#fdecea,stroke:#ea4335

.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 卡住时,可以这样处理:

%%{init: {"theme":"base","themeVariables":{"fontSize":"16px"},"flowchart":{"useMaxWidth":false}}}%% flowchart TD A["拿到 .pyc"] --> B["看 magic,确定 Python 版本"] B --> C["pycdas 全部导出,留作底稿"] C --> D["pycdc 反编译"] D --> E{"有报错或 WARNING?"} E -->|没有| G["重新编译,比对字节码"] E -->|有| F["把每个函数拆出来<br/>单独交给 pycdc"] F --> H{"这个函数成功了?"} H -->|是| G H -->|否| J["对着 pycdas / dis 手工还原"] J --> G G --> K{"一致?"} K -->|是| L["可信"] K -->|否| J

拆函数这一步很管用。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 从需求推出它长什么样

不看代码,只从要解决的问题出发,就能推出它需要哪些零件:

  1. 要把 .pyc 变成密文,得有个加密工具。对应 pyecli 命令。
  2. Python 不认识密文,import 的时候得先解密。所以要往导入系统里挂一个钩子。
  3. 解密需要密钥,而程序是要发给别人运行的,所以密钥只能跟着程序一起发出去。pyconcrete 的做法是安装时用你给的口令生成密钥,编进 C 代码里。
  4. 密钥编在二进制里,每个人的都不一样,所以它没法提供预编译好的安装包,每次安装都得在本机现编 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 许可。

posted @ 2026-11-04 10:46  起飞的乌龟  阅读(1)  评论(0)    收藏  举报