Python代码保护-上

经历了一些事,博客也停更了半年,现在继续写吧。。。

背景

上次分析了一款扫描器,发现其部分代码为python实现,使用pyc方式发布,但是这些pyc却无法使用uncompyle6反编译,显示IndexError: tuple index out of range错误,通过查找资料发现很可能是因为代码被保护了,于是查找了一下python代码保护的一些资料以学习分析,在之后,考虑到我们有很多代码也是python实现,希望分享一下学到的姿势看能不能补充保护能力。

几种文件

在Python代码保护中主要处理这三类文件:

  1. .py:即使用python语法写的源码文件
  2. .pyd.so:它们都是动态链接库文件,前者实际是windows下的dll文件,此处特指使用Python C扩展编译后的二进制可执行文件。
  3. .pyc:它是由魔数+时间戳+序列化的PyCodeObject组成的包含Python编译后的操作码的文件,可以使用marshal库加载,Python的操作码可以使用dis库”反汇编”为可读格式
    1
    2
    3
    4
    5
    6
    7
    8
    import dis
    import marshal

    f = open('test.pyc', 'rb')
    magic = f.read(4)
    timestamp = f.read(4)
    pycode = f.read()
    code = marshal.loads(pycode)
    Python操作码只有100多条,其中小于0x59的指令只有1字节,其他的为3字节。

思路

在不影响程序功能,允许可接受的性能损失的前提下增大逆向分析的难度,被分析的对象是发布后客户所得到的程序,该程序由开发人员从开发的源码包单向生成,即开发人员不直接维护发布后的程序,故不用考虑它的可读性等问题,于是有以下思路。

1.发布编译后的pyc文件

直观的,似乎pyc文件已经看不出源码的痕迹了,但事实上通过decompyleuncompylecrazy-compilers可以轻易的反向得到.py源码。

2.打包成可执行文件

常见的如py2exe,pyInstaller等,事实上这种保护效果很差,和直接提供pyc基本无区别。

3.转换二进制文件

从实现上说应该是C/C++代码再编译成二进制可执行文件发布,明显的C编译后的二进制文件分析难度要大上很多,而且C上的软件保护手段相对成熟很多,通常的实现有两种方式:

  1. 使用Cython实现自动转换:此处说所的Cython是Python的编译器,它能够自动化地将.py文件转换为使用Python C扩展地.c文件,于是顺利成章的可以编译成二进制文件,即上面所提的第二类共享库文件(存在兼容性问题,当代码量过大时不易排查)。
  2. 直接使用Python C/API实现关键部分的代码。

类似的,还有nuitka,shed等工具,但是看了下似乎不太成熟,算一种思路啪。
注:转换为二进制文件再发布将削弱平台的兼容性,需要为对应平台编译对应的共享库,不过这在我们的产品中不必考虑兼容问题。

4.对抗反编译器

思路和二进制上的对抗类似,但是在Python中最常见的就是使用跳转来干扰反编译器,例如使用永假条件转移来跳转到错误的位置,在不影响程序正常执行的情况下将会干扰分析工具。

5.加密代码

即将.py.pyc加密后发布,运行时在内存中解密后再运行,它们一般采用RC4,AES等对称加密算法进行加解密,有名的有两种实现:

  1. 类似Pyarmor,将代码加密存储,使用自己的入口函数加载:
    1
    2
    3
    from pytransfrom import pyarmor_runtime
    pyarmor_runtime() # 准备运行时
    __pyarmor__(__name__, __file__, b'\x06\x0f...') # 第三个参数为被加密后的代码,它会被解密后执行,执行后销毁
  2. 类似nspyprotect,先将py编译为pyc,再递归遍历加密其PyCodeObject的code部分,并设置co_flags标志,通过修改Python的Loader实现在加载时透明解密。

这类通过代码加密实现保护的还有pyprotectpyconcrete等,这类保护看着很厉害,实际上由于是可逆的所以很容易破解,而当代码被解密时,将呈现出完全无保护的状态。

6.代码混淆

此处止python源码级别的混淆,常见的有:

  1. 删除所有注释与文档字符串。
  2. 重命名变量,函数,类等的名称,例如把所有能改的名字全部换成乱码字符串,随机字符串。
  3. 常量字符串编码,例如使用16进制编码分开存储运行时解码并连接。
  4. 插入无关代码,类似花指令,不影响程序原有功能但是干扰分析。
  5. 更改对齐方式,使用一些无意义的空格等干扰阅读。

这是很基本的代码保护方式,但是由于其不可逆,在大型项目下能恶心到分析人员,感觉是总不错的选择,有名的工具有pyminifier,pyobfuscate等。

7.魔改解释器

最简单的,将对操作码进行替换,在不逆向分析解释器的情况下将增加逆向分析难度。

8.Saas

将关键部分放在云端,作为服务提供当然是相对来说最安全的。

分析栗子

xxprotect是某产品使用的代码保护方式,它release的是pyc文件及配套的运行环境,当使用uncompyle反编译pyc时出现以下错误,怀疑是文件经过了混淆:

1
IndexError: tuple index out of range

使用010edit打开pyc文件,并使用对应的模板解析(官方模板最后更新时间是09年,已经无法解析新版本的魔数,简单的解决办法是修改模板文件,直接添加对应的魔数即可),发现字节码中大都是异常的原生python未定义的指令,使用本地的python解释器也无法正常运行它,再测试发现它提供的python解释器可以正常运行此pyc,进一步测试,发现它的python直接编译得到的pyc文件是无任何加密的,其他普通解释器也能解析的,于是猜测:

  1. 修改了python解释器,增加了对自定义指令的支持,这种自定义很可能也就只是操作码替换。
  2. 使用了hook,例如使用动态链接库预处理pyc文件,将其解密后再传至原始的python解释器。
    既然它能运行混淆过的和未混淆的pyc,而且混淆是直接的代码加密,那么很可能有其他的标志位标识它们的不同,通过对比发现被混淆的pyc的code对象co_flags标志多了0x30000,而这是python官方未定义的标志,认定该标志表示加密,接着就是逆向python解释器分析它的解密方法了,简单的分析方法当然是找到对应版本的python自己编译再进行bindiff,简单的分析是操作码替换与RC4加密。于是编写解密代码,通过分析发现它的保护方式为递归遍历code对象,先对部分字节码的操作码进行替换操作,再整体使用RC4加密,并自定义了code对象的co_flags标志位以表明该pyc已被加密,由于已知加密密钥,简单的逆写代码即可实现解密操作,但是code对象的co_code属性是不可写的,于是有三种直观的思路:
  3. 修改code对象的代码,将其co_code属性改为可写的,co_code其实被存储为一个字符串故该操作最为简单,但是需要修改python库代码。
  4. 分析pyc的结构,直接写C代码读取pyc并定点解析被加密的代码,这需要了解pyc的数据结构,使用010editor可以很容易的学习到,但是由于co_consts元组里的元素大小不一需要根据元素类型针对性解析,比较麻烦。
  5. 使用python api/c直接操作python的内存,绕过python对象的数据访问限制。

以下是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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#include <Python.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

//#define DEBUG
#ifdef DEBUG
#define debug(s,...) printf(s,__VA_ARGS__)
#else
#define debug(s) s;
#endif

void my_rc4_decrypt(char *msg, unsigned long msg_size);
void print_hex(const char* data, unsigned int len){
int i = 0;
for(i;i<len;i++){
debug("%x ",data[i]&0xff);
}
debug("\n");
}
void unprotect_code(PyCodeObject *code)
{
char *localcode = (char *)code;
unsigned int total_size = *(int *)(localcode + 16);
char *code_start = localcode + 32;
unsigned int i = 0;

debug("[+] the code len:%d\t contect:", total_size);
print_hex(code_start, total_size);
if (total_size <= 0)
return;
my_rc4_decrypt(code_start, total_size);
debug("[+] decrypted code:");
print_hex(code_start, total_size);
i=0;
debug("[+] start replace opcode...\n");
while (i < total_size)
{
unsigned char opcode = (unsigned char)code_start[i];
//if (opcode >= 90 || opcode < 0)
if (opcode>=90)
{
switch (opcode)
{
case 204:
code_start[i] = 116;
debug(" <-52> ");
break;
case 200:
code_start[i] = 124;
debug(" <-56> ");
break;
case 202:
code_start[i] = 100;
debug(" <-54> ");
break;
default:
break;
}
i += 3;
}
else
{
i += 1;
}
}
debug("\n[+] the final code is:");
print_hex(code_start, total_size);
}

void do_unprotect(PyCodeObject *code)
{
PyObject* consts;
Py_ssize_t len;
unsigned int i ;
if (code->co_flags & 0x30000)
{
debug("[+] the code is encrypted, start decrypt....\n");
unprotect_code((PyCodeObject*)(code->co_code));
code->co_flags &= ~0x30000;
}else{
debug("[+] the code is text, skip....\n");
}
consts = (PyObject*)(code->co_consts);
len = PyTuple_GET_SIZE(consts);
for (i = 0; i < len; i++)
{
PyObject *tmp_obj = PyTuple_GetItem(consts, i);
if (tmp_obj->ob_type == &PyCode_Type)
{
do_unprotect((PyCodeObject *)tmp_obj);
}
}
}

static PyTypeObject PyCode_Type;

static PyObject *unprotect_py(PyObject *self, PyObject *args)
{
PyCodeObject *co;
PyObject *result;
if (PyArg_ParseTuple(args, "O!", &PyCode_Type, &co))
{
debug("[+] start check and decrypt...\n");
do_unprotect(co);
result = &_Py_NoneStruct;
++_Py_NoneStruct.ob_refcnt;
}
return result;
}

static char unprotect_docs[] = "do decrypt for the encrypted .pyc file.\n";

static PyMethodDef unprotect_module_methods[] = {
{"unprotect", (PyCFunction)unprotect_py,
METH_VARARGS, unprotect_docs},
{NULL, NULL, 0, NULL}};

PyMODINIT_FUNC initunprotect_native(void)
{
Py_InitModule3("unprotect_native",unprotect_module_methods,"decrypt the protected .pyc file");
}

再使用pyc调用解密库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def decrypt_pyc(filename, cfile=None):
try:
with open(filename, 'rb') as f:
magic = f.read(4)
timestamp = f.read(4)
code_object = marshal.load(f)
unprotect_native.unprotect(code_object)
if not cfile:
cfile = filename+'.decrypt.pyc'
with open(cfile, 'wb') as fc:
fc.write(magic)
fc.write(timestamp)
marshal.dump(code_object, fc)
except Exception as e:
print(filename)
print(e)

如上即可得到解密后的pyc,再使用uncompyle反编译,因此这种看着用了很高级的加密的保护实际是很不安全的。

总结

在逆向分析人员面前不存在无法破解的保护,只有时间成本问题,上面提了多种主流的保护方法其实可以分为两类:可逆保护与不可逆保护。例如打包,编译为pyc,加密等为可逆的保护,而编译为二进制文件,删除注释,重命名变量名等都是不可逆的保护,不可逆的保护相对来说保护能力更强,在实际应用中考虑到可靠性与工作量,可以只将关键代码使用C扩展再施以其他保护,而其他部分将代码混淆作为基础防护,也可以使用自研的一些代码加密方法提升破解门槛。另外在使用现有工具的时候可以先去搜索下是否已有破解文章与工具存在,以避免做无效保护。