Python字节码解混淆
前言
上次打NISCCTF2019留下来的一道题,关于pyc文件逆向,接着这道题把Python Bytecode解混淆相关的知识和工具全部过一遍。同时在已有的基础上进一步创新得到自己的成果,这是上篇,做基础铺垫和已有工具梳理。
pyc文件结构
首先pyc文件是python源码进行编译之后得到的字节码文件。虽然Python是解释型语言,但并非直接解释源码,而是先编译到字节码然后解释执行字节码。Python2和Python3字节码有区别不通用,同时目前以及可遇见范围内的事实标准都是CPython实现。
Pyc文件由3部分组成:
最开始4个字节是标识此pyc的版本的
Magic Number
, 具体对应关系在Python/import.c
内定义。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52/* Magic word to reject .pyc files generated by other Python versions.
It should change for each incompatible change to the bytecode.
The value of CR and LF is incorporated so if you ever read or write
a .pyc file in text mode the magic number will be wrong; also, the
Apple MPW compiler swaps their values, botching string constants.
The magic numbers must be spaced apart atleast 2 values, as the
-U interpeter flag will cause MAGIC+1 being used. They have been
odd numbers for some time now.
There were a variety of old schemes for setting the magic number.
The current working scheme is to increment the previous value by
10.
Known values:
Python 1.5: 20121
Python 1.5.1: 20121
Python 1.5.2: 20121
Python 1.6: 50428
Python 2.0: 50823
Python 2.0.1: 50823
Python 2.1: 60202
Python 2.1.1: 60202
Python 2.1.2: 60202
Python 2.2: 60717
Python 2.3a0: 62011
Python 2.3a0: 62021
Python 2.3a0: 62011 (!)
Python 2.4a0: 62041
Python 2.4a3: 62051
Python 2.4b1: 62061
Python 2.5a0: 62071
Python 2.5a0: 62081 (ast-branch)
Python 2.5a0: 62091 (with)
Python 2.5a0: 62092 (changed WITH_CLEANUP opcode)
Python 2.5b3: 62101 (fix wrong code: for x, in ...)
Python 2.5b3: 62111 (fix wrong code: x += yield)
Python 2.5c1: 62121 (fix wrong lnotab with for loops and
storing constants that should have been removed)
Python 2.5c2: 62131 (fix wrong code: for x, in ... in listcomp/genexp)
Python 2.6a0: 62151 (peephole optimizations and STORE_MAP opcode)
Python 2.6a1: 62161 (WITH_CLEANUP optimization)
Python 2.7a0: 62171 (optimize list comprehensions/change LIST_APPEND)
Python 2.7a0: 62181 (optimize conditional branches:
introduce POP_JUMP_IF_FALSE and POP_JUMP_IF_TRUE)
Python 2.7a0 62191 (introduce SETUP_WITH)
Python 2.7a0 62201 (introduce BUILD_SET)
Python 2.7a0 62211 (introduce MAP_ADD and SET_ADD)
.
*/接下来四个字节还是pyc产生的时间(TIMESTAMP, 1970.01.01到产生pyc时候的秒数)
接下来是序列化了的
PyCodeObject
,作为整体的字节码对象存在,命名空间为,是pyc文件加载的时候最先执行的字节吗空间。其余的函数在该对象上组织并初始化。
PyCodeObject
可以利用marshal库来反序列化pyc文件里的PyCodeObject
看一下有的字段。
1 | import marshal |
其中最关键的例如:
co_name
这个PyCodeObject
的名称,如<module>
,str2hex
等co_names
这个PyCodeObject
用到的符号(函数,变量)表,如('sys', 'str2hex', 'hex2str', 'p_s', 'p_f', 'count', 'stdout', 'write', 'stdin', 'read', 'flag')
co_varnames
这个PyCodeObject
用到的局部变量名表,如('DIVIDER',)
co_code
这个PyCodeObject
所对应的实际字节码内容co_consts
这个PyCodeObject
之上所有的常量列表,这个很重要,存储了所用得到的所有函数的PyCodeObject
,形成了嵌套关系。
比如:
1 | code.co_consts |
所以结构是PyCodeObject.co_consts
里面包含了所用到的函数的PyCodeObject
,逐层嵌套构造得到整个字节码对象。
co_code
用marshal得到的co_code
里面的字节码以str
存储,一般来说可以用dis
库进行反汇编:
1 | def add(a,b): |
dis
在反汇编的时候会对引用到的参数会进行解析,虽然方便,但也带来了问题。具体的字节码功能可以在官方文档找到。
但在这道题的情况下就不适用了:
1 | dis.dis(code.co_code) |
阅读错误提示和源码可以发现是在解析指令参数的过程中有问题。首先所有的指令可以分为两类,不需要参数和需要参数的,Python字节码在设计的时候故意把没有参数的指令分配在了对应编号的低位,高位都是有参数的,以Include/opcode.h
中的HAVE_ARGUMENT
分界。他们的在二进制级别上的组织是这样的:
[指令]
不需要参数的指令只占用一个字节[指令] [参数低字节] [参数高字节]
需要参数的指令占用三个字节,一个字节指令,两个字节参数
那么按照这个格式来看一下让dis
崩溃的字节码:
1 | list(map(ord,code.co_code[:9])) |
从上面可以看到,第一条指令是JUMP_ABSOLTE 670
,这个offset的指令是真实存在的,所以指令合法。但是第二条指令应该是LOAD_DEREF 28264
,这个index的对象并不存在,在dis
尝试解析的时候就会崩溃。
实际上因为之前的跳转指令所以第二条的非法指令并不会被真实执行到,所以pyc文件作者是故意加入不影响执行的非法指令触发分析软件崩溃,阻碍对该pyc文件的分析。
绕过恶意指令的阻碍
修改dis模块
既然是通过利用引用解析过程完成分析崩溃,那就直接停用引用解析好了。 于是手动修改dis
模块忽略错误,尽可能解析pyc文件。
举个例子,原版:
1 | print opname[op].ljust(20), |
修改后加入try
和except
过滤掉异常。
1 | print opname[op].ljust(20), |
可以得到完整的修改patch文件,之后就能够正常的反编译看字节码了。
1 | diss.dis(code.co_code) |
活跃代码分析
虽然通过patch了dis
的代码绕过了恶意指令,比如pycdc等工具依旧不能正常打开这个恶意pyc文件。故欲将恶意指令全部nop掉方便分析。
思路是这样的:从co_code
开始模拟逻辑执行,需要分支就把两个分支全部执行到。在过程中记录访问到过的offset,最后把除了收集到的offset全部nop掉。
1 | import marshal, sys, opcode, types, dis |
很简单的DFS过程,不赘述,在这里给出完整代码。以下是nop的效果:
1 | [Disassembly] |
调试中建议用自己修改过的CPython
实现,追踪pyc的执行offset,目前pyc文件的调试工具还基本没有,在动态调试上比较被动。
修改CPython启用debug
同时参考Archlinux
的AUR脚本,梳理过程如下:
Clone得到源码
Checkout到所需的版本分支
./configure --with-pydebug
配置启用debug选项并编译在执行上下文设置
__lltrace__ = True
启动字节码级别的执行log
首先是--with-pydebug
启动调试选项激活对应编译分支,之后用__lltrace__
开启对应的log分支,同时关闭FAST_DISPATCH
优化来避免漏掉指令。
小结
这篇博文总结了针对pyc文件的反汇编相关的内容和工具的针对修复方法。
本人投稿至安全客平台。
原文来自安全客,原文链接:https://www.anquanke.com/post/id/185481
声明:本文经安全客授权发布,转载请联系安全客平台。