python一键反编译工具

dis2py 开发思路 ------ 从零打造一个 Python 字节码反编译器

项目地址:github.com/qxqycb/exe2... 我有一个 PyInstaller 打包的 exe,问我能不能把源码弄出来。

市面上能用的 Python 反编译工具就那么几个:uncompyle6、pycdc、decompyle3。但问题是,uncompyle6 只支持到 Python 3.8,decompyle3 停在 3.7,而 pycdc 虽然号称支持到 3.14,但实际用起来你会发现它的输出经常有各种奇怪的 bug------比如函数体里莫名其妙出现 None(None, None) 这种垃圾代码,或者关键函数反编译不完整,只留一句 # WARNING: Decompyle incomplete

最要命的是,这些工具都是 C/C++ 写的,出了问题你根本没法改。你只能等作者更新,或者换一个工具碰运气。

所以我就在想:能不能用 Python 自己写一个?就用标准库里的 dis 模块,把字节码一行一行翻译回源码。

这个想法其实不新鲜。很多人都想过,但大部分人做到一半就放弃了,因为字节码反编译比想象中难得多。不过我当时想的是,就算做不到 100% 还原,能还原 80% 也够用了------剩下的手动补一下就行。

反编译的第一步,是把字节码指令组织成方便处理的形式。Python 的 dis 模块提供了 dis.get_instructions() 函数,可以拿到一个代码对象的所有指令。每条指令有操作码(opname)、参数(argval)、偏移量(offset)等信息。

我写了一个 InstructionStream 类来封装这些指令列表,提供游标和前视功能。这个类的设计思路很简单:我需要一个能"向前看"的迭代器,因为很多模式匹配需要检查接下来几条指令是什么。

python 复制代码
class InstructionStream:
    def peek(self, n=0):
        """前瞻 n 条指令"""
        idx = self.pos + n
        if idx < len(self.instructions):
            return self.instructions[idx]
        return None

有了这个基础,我开始写表达式求值器。

表达式求值是整个反编译器的基础。一个简单的赋值语句 x = a + b,在字节码里是:

css 复制代码
LOAD_FAST    a
LOAD_FAST    b
BINARY_OP    +          # Python 3.11
STORE_FAST   x

看起来很简单对吧?但问题是,表达式可以嵌套得很深。比如 x = obj.method(a, b + c) 会生成一堆指令,你需要从后往前回溯,重建整个表达式树。

我写的 _backtrack_expr 函数就是做这个的。它从表达式末尾的指令开始,递归地向前回溯,遇到二元运算就分别求左右操作数,遇到函数调用就收集参数,遇到属性访问就拼接 . 符号。

这个函数是整个项目最核心、也最复杂的一个函数。它需要处理:

  • 常量(LOAD_CONST)------ 数字、字符串、None、True/False
  • 变量(LOAD_FASTLOAD_DEREFLOAD_GLOBALLOAD_NAME
  • 属性访问(LOAD_ATTR)------ obj.attr
  • 方法调用(LOAD_METHOD)------ obj.method(args)
  • 函数调用(CALLPRECALL)------ Python 3.11 新增了 PRECALL 指令
  • 二元运算(BINARY_OP)------ Python 3.11 把之前分散的 BINARY_ADD、BINARY_SUBTRACT 等合并成了一个 BINARY_OP
  • 比较运算(COMPARE_OP
  • 下标访问(BINARY_SUBSCR)------ obj[key]
  • 列表/元组/字典/集合构建(BUILD_LISTBUILD_TUPLEBUILD_MAPBUILD_SET
  • f-string(BUILD_STRINGFORMAT_VALUE
  • 一元运算(UNARY_NEGATIVEUNARY_NOT 等)

每个操作码的处理逻辑都不太一样。比如 LOAD_ATTR 需要和前一条指令拼接成 prev.attr 的形式,CALL 需要收集前面的参数,BUILD_STRING 需要把多个字符串片段拼接成 f-string。

有了表达式求值,接下来就是识别各种语句模式。

我写了一套模式匹配函数,每个函数识别一种特定的语句模式:

  • _match_simple_assign ------ 识别 target = value 赋值
  • _match_dict_literal ------ 识别 {key: value, ...} 字典字面量
  • _match_for_range ------ 识别 for var in range(...):
  • _match_try_except ------ 识别 try: / except:
  • _match_if_block ------ 识别 if condition: / else:
  • _match_return ------ 识别 return 语句
  • _match_binary_op_assign ------ 识别 var += val 增强赋值
  • _match_complex_call ------ 识别带关键字参数的复杂调用
  • _match_raise ------ 识别 raise 语句
  • _match_print ------ 识别 print() 调用
  • _match_with ------ 识别 with xxx: 语句

这些模式匹配器是线性扫描的,每条指令依次检查是否匹配某个模式。如果匹配成功,就输出对应的 Python 代码;如果都不匹配,就跳过。

这个阶段的反编译器已经能处理一些简单的函数了,但还原率很低。因为很多复杂的控制流结构------比如嵌套的 if/for/try、布尔表达式、推导式------都还没处理。

线性扫描最大的问题是无法处理嵌套。比如一个 if 里面嵌套一个 forfor 里面又嵌套一个 if,这种结构线性扫描根本搞不定。

于是我把反编译器重构成了块级递归的形式。核心思想是:把字节码分成若干个"块",每个块对应一个代码区域(比如 if 的 then 分支、else 分支),然后递归地反编译每个块。

Python 3.11 引入了一个重要的变化:异常处理不再使用 SETUP_FINALLYPOP_BLOCK 等指令,而是使用异常表(exception table)。异常表是一个独立的数据结构,记录了每个 try 块的起止偏移和对应的 handler 偏移。

这个变化让反编译变得更容易,因为异常表的边界是精确的------你不需要通过扫描指令来猜测 try 块的结束位置,直接查表就行。

我写了一个 _parse_exception_table 函数来解析异常表。Python 3.11 的异常表使用 varint 编码,每个条目包含四个字段:start、end、target、depth。其中 depth=0 表示这是最外层的 try 块,depth>=1 表示嵌套的 except/finally 子句。

ini 复制代码
try_start_info = {}
for s, e, t, d in exc_table:
    if d == 0 and e - s > 2:
        try_start_info[s] = (e, t)

有了异常表,try/except 的边界就非常清楚了。try 体的范围是 [start, end),handler 的起始位置是 target

_decompile_block 函数是块级反编译的核心。它接受一个指令范围 [start_idx, end_idx],递归地反编译这个范围内的代码。

在遍历指令时,它会检查每条指令是否属于某个嵌套结构:

  • 如果遇到 FOR_ITER,说明这是一个 for 循环,递归处理循环体
  • 如果遇到 POP_JUMP_FORWARD_IF_FALSE,说明这是一个 if 条件,递归处理 if 体和 else 体
  • 如果遇到 PUSH_EXC_INFO / CHECK_EXC_MATCH,说明进入了异常处理
  • 如果遇到 BEFORE_WITH,说明这是一个 with 语句

对于每种嵌套结构,它会:

  1. 确定结构的边界(开始和结束位置)
  2. 递归调用 _decompile_block 处理子块
  3. 跳过已处理的指令,继续处理后续代码

这种递归结构让处理任意深度的嵌套成为可能。

推导式(list comprehension、dict comprehension、set comprehension、generator expression)在字节码中是一个独立的代码对象。比如 [x for x in y] 会生成一个 <listcomp> 代码对象。

我写了一个 _match_comprehension 函数来识别推导式模式:LOAD_CONST <comp_code> + MAKE_FUNCTION + ... + GET_ITER + CALL

然后又写了一个 _decompile_comprehension 函数来反编译推导式内部代码,提取循环变量、表达式和条件。

推导式的输出格式是 [expr for var in iterable if condition],不同的推导式类型用不同的括号:list 用 [],dict 和 set 用 {},generator 用 ()

随着测试的深入,我发现表达式求值器有很多遗漏。以下是我逐步添加的功能:

Python 的 andor 是短路运算符。a and b 在字节码中不是简单的二元运算,而是一个条件跳转:

css 复制代码
LOAD_FAST    a
JUMP_IF_FALSE_OR_POP    target   # 如果 a 为假,跳到 target
LOAD_FAST    b
>> target:

这个模式在 _backtrack_expr 中很难处理,因为它是向后的线性回溯,而布尔运算涉及向前跳转。

我的解决方案是写了一个 _reconstruct_expr 包装函数。它在 _backtrack_expr 返回后,继续检查返回的索引是否指向一个布尔操作符(JUMP_IF_FALSE_OR_POPJUMP_IF_TRUE_OR_POP)。如果是,就继续回溯左操作数,然后组合成 left and rightleft or right

但这还不够。对于 a and b or c 这种混合表达式,字节码中会出现 POP_JUMP_FORWARD_IF_FALSEJUMP_IF_TRUE_OR_POP 的组合。POP_JUMP_FORWARD_IF_FALSEand 的短路跳转,JUMP_IF_TRUE_OR_POPor 的短路跳转。

处理这种混合表达式需要更复杂的逻辑。我采用的方法是:在布尔链中检测 POP_JUMP_FORWARD_IF_FALSE,找到它的右操作数(向前扫描到下一个布尔操作符),然后替换当前表达式中的右操作数部分。

这里有一个细节:and 的优先级高于 or,所以 a and b or c 应该被解析为 (a and b) or c。在回溯链中,or 会先被处理(因为它更靠近表达式末尾),然后 and 需要"拆开"已经组合好的表达式,把 b 替换为 (a and b)

这个逻辑用字符串替换实现,虽然不够优雅,但能处理绝大多数实际场景。

三元表达式在字节码中的模式是:

ruby 复制代码
cond
POP_JUMP_FORWARD_IF_FALSE   else_target
true_val
JUMP_FORWARD                end_target
>> else_target:
false_val
>> end_target:

这个模式和 if/else 语句非常相似,区别在于三元表达式的"体"只有一个表达式,而 if 语句的体是一个代码块。

我的检测方法是:在 _reconstruct_expr 中,当遇到 JUMP_FORWARD 时,检查它的跳转目标是否恰好是当前语句的位置。如果是,就说明这是一个三元表达式:true_val 在 JUMP_FORWARD 之前,false_val 是当前已经求值出的表达式,cond 在 POP_JUMP_FORWARD_IF_FALSE 之前。

同时,在 if 语句处理器中,我也加了检测:如果 body 中只有一个表达式且末尾是 JUMP_FORWARD 跳到 body 结束之后,就跳过 if 处理,交给 _reconstruct_expr 处理。

切片 lst[1:3] 的字节码是:

复制代码
LOAD_FAST    lst
LOAD_CONST   1
LOAD_CONST   3
BUILD_SLICE  2
BINARY_SUBSCR

BUILD_SLICE 的参数表示切片有几个部分(2 表示 start:stop,3 表示 start:stop:step)。在 _backtrack_expr 中,我添加了 BUILD_SLICE 的处理,把各个部分组合成 start:stopstart:stop:step 的形式。

None 值表示省略的部分,比如 lst[1:] 的 stop 是 None,我把它转换为空字符串。

lambda 在字节码中是一个独立的代码对象,通过 MAKE_FUNCTION 创建:

css 复制代码
LOAD_CONST    <code object <lambda>>
MAKE_FUNCTION 0

我在 _backtrack_expr 中添加了 MAKE_FUNCTION 的处理。当遇到 MAKE_FUNCTION 时,检查前面的 LOAD_CONST 是否是一个 lambda 代码对象。如果是,就反编译 lambda 体,提取参数列表,输出 lambda params: body

lambda 默认参数的处理稍微复杂一点。默认值通过一个元组传递,在 LOAD_CONST 代码对象之前:

python 复制代码
LOAD_CONST    (10,)          # 默认值元组
LOAD_CONST    <lambda code>
MAKE_FUNCTION 1              # flags=1 表示有默认值

我通过 MAKE_FUNCTION 的 flags 参数判断是否有默认值(flags & 0x01),如果有就向前找默认值元组,然后传给 _build_params 函数。

obj.method1().method2().method3() 的字节码是一串 LOAD_METHOD + PRECALL + CALL 的组合:

objectivec 复制代码
LOAD_FAST     obj
LOAD_METHOD   method1
PRECALL       0
CALL          0
LOAD_METHOD   method2
PRECALL       0
CALL          0
LOAD_METHOD   method3
PRECALL       0
CALL          0

_backtrack_expr 中,LOAD_METHOD 的处理会收集参数并生成 .method(args) 的字符串。由于 _backtrack_expr 是递归的,每次遇到 LOAD_METHOD 都会在前一个表达式的基础上追加 .method(args),自然就形成了链式调用。

while 循环在字节码中看起来和 if 语句很像------都是 POP_JUMP_FORWARD_IF_FALSE 开头。区别在于,while 循环的 body 末尾有一个回跳指令(JUMP_BACKWARDPOP_JUMP_BACKWARD_IF_TRUE)。

ruby 复制代码
>> loop_start:
condition
POP_JUMP_FORWARD_IF_FALSE   exit_target
body
JUMP_BACKWARD               loop_start
>> exit_target:

我的检测方法是在 if 语句处理器中,检查 body 的最后一条非 skip 指令是否是回跳指令。如果是,且回跳目标在 body 内部,就说明这是一个 while 循环。

对于 while True: 的情况,字节码中没有 POP_JUMP_FORWARD_IF_FALSE(因为条件永远为真),而是用 NOP 占位,body 末尾用 JUMP_BACKWARD 跳回 body 开头。我通过检测 NOP 指令是否被 JUMP_BACKWARD 作为目标来识别这种模式。

Python 的 if a and b: 在字节码中不是一条指令,而是两条 POP_JUMP_FORWARD_IF_FALSE,都跳转到同一个 else 目标:

less 复制代码
LOAD_FAST    a
POP_JUMP_FORWARD_IF_FALSE   else_target
LOAD_FAST    b
POP_JUMP_FORWARD_IF_FALSE   else_target
# if body
>> else_target:
# else body

我的检测方法是在 body 中扫描是否有另一个 POP_JUMP_FORWARD_IF_FALSE 跳转到相同的目标。如果有,就把两个条件合并成 a and b

if a or b: 的检测类似,只是第一个跳转是 POP_JUMP_FORWARD_IF_TRUE(如果 a 为真就直接进入 body),第二个是 POP_JUMP_FORWARD_IF_FALSE(如果 b 为假就跳到 else)。

with 语句的字节码结构比较复杂,因为它包含了 __enter____exit__ 的隐式调用:

python 复制代码
BEFORE_WITH
STORE_FAST    f           # as f
# with body
LOAD_CONST    None        # __exit__ 参数
LOAD_CONST    None
LOAD_CONST    None
PRECALL       2
CALL          2           # __exit__()
POP_TOP
JUMP_FORWARD  N           # 跳过异常处理器
# 异常处理器 (PUSH_EXC_INFO, WITH_EXCEPT_START, ...)
>> target:
# with 之后的代码

我的 with 语句处理器会:

  1. 识别 BEFORE_WITH 指令
  2. 回溯 with 表达式(如 open('test.txt')
  3. 检测 STORE_FAST 获取 as 变量名
  4. 扫描 body 直到找到 __exit__ 调用(三个连续的 LOAD_CONST None
  5. 跳过 __exit__ 调用和异常处理器

早期版本的一个 bug 是清理循环太激进,把 with 之后的正常代码(如 return 语句)也跳过了。修复方法是精确计算 JUMP_FORWARD 的跳转目标,只跳到目标位置,不继续跳过后续的正常代码。

try/except 的处理主要依赖异常表。Python 3.11 的异常表提供了精确的 try 块边界,所以 try 体的范围是确定的。

except 子句的处理比较复杂。在字节码中,except 的入口是 PUSH_EXC_INFO + CHECK_EXC_MATCH 的组合。CHECK_EXC_MATCH 检查异常类型是否匹配,如果匹配就进入 except 体,否则跳转到下一个 except 或 re-raise。

我花了很长时间处理 except 体的边界。一个关键问题是 POP_EXCEPT 指令的位置------它标志着异常处理入口的结束,但 except 体的实际代码在 POP_EXCEPT 之后。我最初的代码把 POP_EXCEPT 当成了 except 体的开始,导致 except 体总是空的。修复方法是在找到 except 入口后,跳过 POP_EXCEPT,再从下一个指令开始作为 except 体。

else 子句的检测相对简单:else 子句是 try 体和 handler 之间的代码。如果 try_end 和 handler_start 之间有非 skip 指令,就说明有 else 子句。

finally 子句的检测需要区分 handler 的类型。如果 handler 中有 CHECK_EXC_MATCH,说明是 except 处理器;如果没有,说明是 finally 处理器。finally 的 body 在两个地方出现:正常路径(在 try 体之后)和异常路径(在 handler 中)。

装饰器是 Python 中非常常见的模式。在字节码中,装饰器的形式是:

swift 复制代码
LOAD_NAME    decorator
LOAD_CONST   <code object func>
MAKE_FUNCTION 0
PRECALL      0
CALL         0
STORE_NAME   func

链式装饰器 @dec1 @dec2 的字节码会有多个 CALL:

swift 复制代码
LOAD_NAME    dec1
LOAD_NAME    dec2
LOAD_CONST   <code object func>
MAKE_FUNCTION 0
PRECALL      0
CALL         0          # dec2(func)
PRECALL      0
CALL         0          # dec1(dec2(func))
STORE_NAME   func

带参数的装饰器 @dec(args) 的字节码会更复杂,装饰器表达式本身包含一个 CALL。

我的 _try_parse_decorator 函数的检测逻辑是:

  1. STORE_NAME 往前扫描,找到 CALL 指令
  2. 收集所有连续的 CALL 指令(每个对应一个装饰器调用)
  3. 找到 MAKE_FUNCTION 和前面的 LOAD_CONST(代码对象)
  4. LOAD_CONST 之前回溯每个装饰器的表达式
  5. 装饰器的数量等于 CALL 的数量

对于类方法装饰器(如 @staticmethod@classmethod@property),检测逻辑相同,但需要在类体字节码中执行。我在 decompile_module 中为每个类体单独解析字节码,建立方法名到装饰器列表的映射。

相关推荐
weixin_BYSJ19871 小时前
springboot技能与工具共享小程序---附源码29657
java·javascript·spring boot·python·小程序·django·php
weixin_BYSJ19871 小时前
flask民族服饰饰品商城小程序---附源码37399
java·javascript·spring boot·python·小程序·django·php
帅气的小峰2 小时前
pybind11与nanobind可以共存吗?如何迁移?
c++·python·cuda·推理引擎
用户298698530142 小时前
从入门到自动化:TXT 转 Word 的在线工具与代码实战方案
人工智能·后端·python
W_326002 小时前
Python-OpenCV:库、环境搭建、图片与摄像头解析
开发语言·图像处理·python·opencv·机器学习
抓不住时间的沙2 小时前
N1搭建Hexo个人博客,部署到Github
python·docker·node.js·debian·github·arm
W_326002 小时前
Python-OpenCV:画布、图形、文字、调色滑动条
图像处理·python·opencv·机器学习·计算机视觉
Promising_GEO2 小时前
新电脑科研环境配置指南:Miniforge + PyCharm + GitHub 从安装到可用
ide·python·pycharm·github·地理
Patrick在香港2 小时前
Claude API 成本直降90%:Prompt Caching 提示词缓存 Python 实战
python·缓存·prompt