多年前看过一篇,针对重度混淆过的Python解释器进行逆向工程。
Reversing Obfuscated Python Applications Breaking the dropbox client on windows – ExtremeCoders <extremecoders@mail.com> [2014-07-03]
http://www.slideshare.net/extremecoders/reversing-obfuscated-python-applications-dropbox-38138420
其目标是Dropbox,版本是2.x。现在基本都是3.x了,但很多思路是相通的,没看过的,值得一阅。
XYM在工作中遭遇现实世界的变形Python 3.9解释器,其保护力度远不如Dropbox,但对之逆向工程有现实意义。其some.pyc的”magic number”非标准值,序列化的PyObject被混淆过。XYM设法反混淆后,发现opcode映射关系置换过,很多CTF题有类似场景,我从未关心过,但这次不一样,是现实世界场景。
单就「opcode置换」,简单说一下应对思路。先写一个opcode_full.py,顾名思义,对该文件动用dis模块,可生成「所有种类opcode。示例
————————————————————————–
import dis
def func ( n ) :
for i in range( n )[::-1] :
yield -i
dis.dis( func )
————————————————————————–
$ python3.9 opcode_full.py
4 0 LOAD_GLOBAL 0 (range)
2 LOAD_FAST 0 (n)
4 CALL_FUNCTION 1
6 LOAD_CONST 0 (None)
8 LOAD_CONST 0 (None)
10 LOAD_CONST 1 (-1)
12 BUILD_SLICE 3
14 BINARY_SUBSCR
16 GET_ITER
>> 18 FOR_ITER 12 (to 32)
20 STORE_FAST 1 (i)
5 22 LOAD_FAST 1 (i)
24 UNARY_NEGATIVE
26 YIELD_VALUE
28 POP_TOP
30 JUMP_ABSOLUTE 18
>> 32 LOAD_CONST 0 (None)
34 RETURN_VALUE
并不要求func()可执行,可用dis反汇编即可。直接调dis.dis()太粗暴,可换成dis.get_instructions()之类的,精准输出instr.opname、instr.opcode等等。确保opcode_full.py涵盖尽可能多的opcode。
网友UID(1889059107)提到一个python2的现成实现
https://github.com/dkw72n/notes/tree/master/scripting/python/py27_all_ops
有些片段或可借用。就就是直接问GPT,比如
「什么样的python3代码会生成 UNARY_POSITIVE 这样的字节码」
有些opcode不易生成,比如
LIST_APPEND
SET_ADD
DICT_UPDATE
SETUP_ANNOTATIONS
ROT_FOUR
LOAD_CLASSDEREF
MAP_ADD
DELETE_DEREF
NOP
PRINT_EXPR
可写程序在Python安装目录下遍历所有some.pyc,解析并从中搜索指定opcode,进而查看相应some.py,借鉴源码实现,补充至opcode_full.py。
Python 3.9及更低版本无法正常生成NOP,PRINT_EXPR只在”interactive mode”生成并使用,换句话说,正常some.pyc中不可能出现这两个opcode。
参看
How to get the Python interpreter to emit a NOP instruction – [2017-06-05]
https://stackoverflow.com/questions/44379192/how-to-get-the-python-interpreter-to-emit-a-nop-instruction
This appears to be impossible. NOP opcodes are only generated by the
peephole optimizer, but the last step of peephole optimization removes all
NOPs and retargets jumps for the new instruction indices.
参
cpython-3.9\Python\peephole.c
PyCode_Optimize()中有个注释,”Remove NOPs and fixup jump targets”,查看其附近代码。
网友UID(2178100891)提到,Python 3.10及更高版本,下列代码会生成NOP
————————————————————————–
while True :
pass
————————————————————————–
可在此测试
https://python.godbolt.org/
假设opcode_full.py已能生成指定版本解释器尽可能多种类的opcode,除了NOP、PRINT_EXPR。接下来,用标准解释器、变形解释器分别反汇编opcode_full.py,各有一份输出。变形解释器可能会出幺蛾子,根据抛出的异常临时Patch相应文件,确保能采集到opcode_full.py所有instr.opcode即可,其instr.opname无意义。对比两份输出,即可找出尽可能多种类的opcode映射关系,此过程可用各种Linux文本处理工具,不必手工比对。显然,opcode_full.py是Python版本强相关的,标准解释器、变形解释器必须是同一Python版本。
假设通过opcode_full.py找出除NOP、PRINT_EXPR之外所有种类opcode映射关系,对于绝大多数逆向工程,足矣,缺少这两个opcode,没有实际影响。有了新的opcode映射关系,可临时修改标准解释器的opcode.py使dis模块可用,输出正确的反汇编结果。还可修改python_*.map并重新编译,使pycdas、pycdc可用。
Decompyle++ A Python Byte-code Disassembler/Decompiler
https://github.com/zrax/pycdc
python_*.map形如
————————————————————————–
72 POP_TOP
60 ROT_TWO
81 ROT_THREE
42 DUP_TOP
————————————————————————–
python_*.map第1列并不要求单向递增,没有排序一说。瞎填NOP、PRINT_EXPR的值,也无实际影响。
有洁癖或其他特殊需求时,可通过逆向工程找出NOP、PRINT_EXPR的映射。在SourceInsight中打开
cpython-3.9\Include\opcode.h
在该文件中定位PRINT_EXPR,右键”Jump To Caller”,选中ceval,实际跳至_PyEval_EvalFrameDefault()的”case TARGET(PRINT_EXPR)”,附近代码出现特征串”lost sys.displayhook”。
用IDA打开变形Python解释器,Strings窗口搜”lost sys.displayhook”,交叉引用定位”case 7″,表示PRINT_EXPR现在映射至7。
NOP没有特征串借力,得用其他奇技淫巧,留作逆向工程练习吧。这个无须用变形解释器练手,就用标准解释器好了,思路可以平移。得假设跳转表9号元素不是NOP,莫犯低级错误。
若「opcode置换」已影响到HAVE_ARGUMENT,应对措施更复杂些,但思路普适。
这事儿属于一次投入、反复受益。无论是打CTF,还是现实世界逆向工程,值得长期维护不同版本opcode_full.py,不但快速确定opcode映射关系,还可测试Pyton反汇编器、反编译器、010 Editor模板等等。