我就是小菜鸡本鸡了,不是很会写东西,请各位大佬多多见谅。本文基于python2.7,因为python3并不是很懂。
python文件如果要发布的话,有时候还是难免想保护一下自己的源码,有些人就直接编译成了pyc文件,因为这样既可以保留跨平台的特性,又可以不能直接看到代码,也看到网上很多人说为了保护自己的代码可以编译成pyc文件。
用pyc文件可以保护python代码的想法其实是不正确的,pyc文件是可以被很容易反编译的,比如说比较著名的uncompyle6库(https://github.com/rocky/python-uncompyle6),用来反编译文件最爽不过了,几乎支持python全版本的pyc文件的反编译。
py文件编译成pyc文件可以使用 python -m xxx.py
常规pyc文件的结构如下:
magic 03f30d0a
日期 aa813e59 (Mon Jun 12 19:57:30 2017)
code 代码对象
首先pyc前四个字节是魔术字,魔术字是用来标记python版本的标识。如03f30d0a
是python2.7的标识。
在python2.7中,获取魔术字的方式:
import imp
magic = imp.get_magic()
print(magic)
魔术字之后四个字节是时间戳,时间戳解开的方式如下:
import time
import struct
content = open("a.pyc","rb").read()
timestamp = content[4:8]
timestamp = struct.unpack("<I"timestamp)[0]
time_str = time.strftime("%Y-%m-%d%H:%M:%S",time.localtime(timestamp))
print(time_str)
2019-02-21 10:47:59
去掉前8个字节,剩下的就是个code的对象,code对象的结构如下:
typedef struct {
PyObject_HEAD
int co_argcount; /* 位置参数个数 */
int co_nlocals; /* 局部变量个数 */
int co_stacksize; /* 栈大小 */
int co_flags;
PyObject *co_code; /* 字节码指令序列 */
PyObject *co_consts; /* 所有常量集合 */
PyObject *co_names; /* 所有符号名称集合 */
PyObject *co_varnames; /* 局部变量名称集合 */
PyObject *co_freevars; /* 闭包用的的变量名集合 */
PyObject *co_cellvars; /* 内部嵌套函数引用的变量名集合 */
PyObject *co_filename; /* 代码所在文件名 */
PyObject *co_name; /* 模块名|函数名|类名 */
int co_firstlineno; /* 代码块在文件中的起始行号 */
PyObject *co_lnotab; /* 字节码指令和行号的对应关系 */
void *co_zombieframe; /* for optimization only (see frameobject.c) */
} PyCodeObject;
这个code对象可以使用marshal库进行加载,加载方式如下
# 接上方代码
code_bytes = content[8:]
import marshal
co = marshal.loads(code_bytes)
print co
<code object <module> at 0x10f8f85b0, file "a.py", line 3>
加载起来以后是个code对象,code对象是可以加载成模块的。
import imp
# 创建一个空的模块
m = imp.new_module("test module")
# 这个co对象是一个code对象,这个对象里面包含的内容是一系列的操作,
# 执行这个code对象,解释出来的变量值都会放到m对象的空间里面
exec(co,m.__dict__)
print dir(m)
['__builtins__', '__doc__', '__name__', '__package__', 'a', 'b']
示例的a.py里面没什么东西:
a = 123
b = 456
这文件解释出来的变量就放到m的空间里面了,调用M对象的属性就能调用的这个py文件。
通过上面的方式,我们可以进行编译py文件,提取code对象,然后就可以实现单文件的支持库的加载了。样例:
import marshal
import sys
import imp
code = """
a = 123
b = 456
"""
c = compile(code, "<string>", "exec")
m = imp.new_module("t_mod")
exec (c, m.__dict__)
sys.modules["t_mod"] = m
import t_mod
print(t_mod)
print(t_mod.a)
print(t_mod.b)
输出结果:
<module 't_mod' (built-in)>
123
456
pyc里面有code对象,code对象中的功能部分都在code.co_code中,该属性的内容是字符串对象,实际上是一串动作的集合。
python中有一个反编译的字节码到助记符的库,叫dis
,这个库的功能就和Windows中静态分析二进制的工具很像,把二进制文件转成汇编代码。在dis库的帮助文档(https://docs.python.org/2/library/dis.html)中有描述每个字节码的用途,每个字节码名字找不到的可以去python的库opcode
中看一下。
就比如NOP
在opcode中是这样添加进来的def_op('NOP', 9)
,所以\x09
就是NOP
的意思。
一般的,每三个字节码或者一个字节码是一组。有些字节码是不需要参数的,比如 \x00
这个表示的是停止代码,停了,不需要参数。有些字节码是需要参数的,比如 \x64\x00\x00
加载常量表中第1个常量(常量表是元组,常量表可通过code.co_consts
访问,第一个成员下标为0)。在python的字节码中有一个分水岭,就是\x5a
,在opcode模块中就是90
,如果 opcode<90
表示无参数,反之则有参数。
有关于python的字节码都是什么意思,可以参考dis库的帮助文档,由于篇幅过长,就不在这里贴出来了。
py代码的混淆就是针对写出来的py代码里面插入一些无意义的分之跳转,将原本的有意义的变量名字,改成无意义的名字,为了加大难度,有时候还会将变量名改成类似的名称。
比如iIl1iI1l
和iI1liIl1
,就是类似这种相近的名字,在手工进行解密,还是非常头疼的,原本的代码也不清楚什么意图,通过名字也根本猜不出来。
py代码混淆的结果,主要体现在那些只能读懂代码,这种人基本都能拦住一些,但是如果是python大佬级别的,找个IDE,重构一下名字,就很快能看懂什么意思了。
推荐Python source code obfuscator:
https://github.com/astrand/pyobfuscate
先举个例子:
if 1+1 == 2:
print "hello"
else:
print "world"
反编译结果是这样子的:
64 05 00 // LOAD_CONST 5 (2)
64 01 00 // LOAD_CONST 1 (2)
6B 02 00 // COMPARE_OP 2 (==)
72 14 00 // POP_JUMP_IF_FALSE 20
64 02 00 // LOAD_CONST 2 ("hello")
47 -- -- // PRINT_ITEM
48 -- -- // PRINT_NEWLINE
6E 05 00 // JUMP_FORWARD 5 (to 25)
64 03 00 // LOAD_CONST 3 ('world')
47 -- -- // PRINT_ITEM
48 -- -- // PRINT_NEWLINE
64 04 00 // LOAD_CONST 4 (None)
53 -- -- // RETURN_VALUE
这个反编译出来的助记符还是比较明白的,大概可以按照这个结构写出来原本的代码的形式。
如果我们想要在其中插入一条指令(插入一条指令就是为了增大难度,同时可能会让反编译工具报错),怎么办呢?
假如说我们插入这样一条混淆指令:
71 06 00 // JUMP_ABSOLUTE 6
64 ff ff // LOAD_CONST 65535
上面这段混淆的指令如果是使用uncompyle
的情况下,肯定是反编译失败的,因为插入的第二条指令加载了一个不存在的常量,导致下标超出,引发异常。
如果是使用dis
库直接反编译code
对象,也是反编译失败的,原因是dis
库在反编译code
对象的时候会尝试去常量表里面把变量取出来打印。但是可以dis
库的进行反编译 code.co_code
属性,就可以反编译成功,原因是这个对象为纯粹的字节码,并不会包含常量表,所以不会出现下标异常。
那么接下来我们把上面这段代码放到我们原本的代码中,挑一个比较典型的位置。
64 05 00 // LOAD_CONST 5 (2)
64 01 00 // LOAD_CONST 1 (2)
6B 02 00 // COMPARE_OP 2 (==)
72 14 00 // POP_JUMP_IF_FALSE 20
// 插入的段
71 12 00 // JUMP_ABSOLUTE 18
64 ff ff // LOAD_CONST 65535
64 02 00 // LOAD_CONST 2 ("hello")
47 -- -- // PRINT_ITEM
48 -- -- // PRINT_NEWLINE
6E 05 00 // JUMP_FORWARD 5 (to 25)
64 03 00 // LOAD_CONST 3 ('world')
47 -- -- // PRINT_ITEM
48 -- -- // PRINT_NEWLINE
64 04 00 // LOAD_CONST 4 (None)
53 -- -- // RETURN_VALUE
如果我们把它放到这个位置,这个第二段的位置,因为我们使用的是一个绝对跳转的指令,所以我们需要查出来jump需要跳到哪里合适,可以跳过我们的异常代码部分。计算出来的是如果我们要跳到原本第二段的位置就需要跳到18的位置(从第一个字节查下标)
当我们插入进去后,发现又出问题了,我们需要考虑第一段中最后一个跳转语句,它原本是要跳到20的,但是我们在它之后又插入了6字节长度的字节码,所以导致它跳转的位置是有问题的,我们需要修复这个跳转问题,这个修复的逻辑我觉得是这样的,如果插入的位置,在影响范围内,就对跳转地址进行更改,增加插入字节的长度。
经过我长(san)久(tian)的研究,发现python字节码混淆主要的成功和失败原因都在跳来跳去。先说个坑,以前的时候我手动改的时候,改完之后发现如果只是修改不插入进去,字节码就能运行,如果长度有变化,字节码就坏了,后来发现前面有长度的计算的。
64 05 00 // LOAD_CONST 5 (2)
64 01 00 // LOAD_CONST 1 (2)
6B 02 00 // COMPARE_OP 2 (==)
72 1a 00 // POP_JUMP_IF_FALSE 26 !需要修复的位置
// 插入的段
71 12 00 // JUMP_ABSOLUTE 18
64 ff ff // LOAD_CONST 65535
47 -- -- // PRINT_ITEM
48 -- -- // PRINT_NEWLINE
6E 05 00 // JUMP_FORWARD 5 (to 25)
64 03 00 // LOAD_CONST 3 ('world')
47 -- -- // PRINT_ITEM
48 -- -- // PRINT_NEWLINE
64 04 00 // LOAD_CONST 4 (None)
53 -- -- // RETURN_VALUE
修复以后就是这样的样子了,因为这里面只存在一个跳转语句,也就是说我们插入数据只能影响到一条跳转的执行。
这是在大佬的博客(https://blog.csdn.net/ir0nf1st/article/details/61650984)看到的东西:
# 例1 Python单重叠指令
0 JUMP_ABSOLUTE [71 05 00] 5
3 PRINT_ITEM [47 -- --]
4 LOAD_CONST [64 64 01] 356
7 STOP_CODE [00 -- --]
# 例1 实际执行
0 JUMP_ABSOLUTE [71 05 00] 5
5 LOAD_CONST [64 01 00] 1
这个例子里面就是第一句的JUMP_ABSOLUTE
跳到了一个很神奇的地方,如果按照dis库解释字节码的操作来看,就是会出问题的,因为第一行跳到5的位置刚好是第三行,LOAD_CONST
的参数地址的位置,但是这个参数地址的位置和后面又拼接完整了一条指令,所以在dis解释的时候因为字节码重叠的问题导致输出有问题,我觉得这个还可以改一下,改成以下形式:
71 04 00 // JUMP_ABSOLUTE 4
64 71 09 // LOAD_CONST 2417
00 -- -- // STOP_CODE
64 ff ?? // LOAD_CONST xxxx
最后一条指令故意空缺了一个字节的位置,这个字节是故意为了拼接原来的字节码,如果运气好的话,原来的字节码会有一部分会变成看起来是乱数据的字节码。
就比如在后面插入一条三个字节码的指令,我们插入LOAD_CONST 1
,对应的字节码为64 01 00
,放到这个后面就会出现:
71 04 00 // JUMP_ABSOLUTE 4
64 71 09 // LOAD_CONST 2417
00 -- -- // STOP_CODE
64 ff 64 // LOAD_CONST 25855
01 -- -- // POP_TOP
00 -- -- // STOP_CODE
就会有如上的效果,单纯的看dis反编译的结果就会发现是乱序的,当然这些只是对工具有一些作用,对人工的对抗还是不太好。
基础代码的混淆知识讲解到此结束。
接下来写一写如何把自己的想法转变成代码,自己写过很多次混淆的工具,写是写,但是总是会出各种奇怪的bug。问题的根源就是跳转的位置计算有问题,每次都想插入不同的payload,还想换着法的插入,查来查去就容易算错。其他方面也有一些问题,就是插入的位置不对,有些字节码前面不能插入东西,插进去就报错。
总结了一下就是下面几个opcode
71 # 跳转绝对位置
64 # 加载常量
6c # 导入支持库
65 # 通过变量名加载内容
84 # 创建函数
72 # 如果上方表达式不成立跳转到
5b # DELETE_NAME
48 # 换行
6e # 跳出循环
在这些字节码前面插入东西是没什么问题的,可能还有其他的字节码前面插入东西也没什么问题,但是懒得找了,只是找一些常用指令,问题不太大就行。如果要混淆,首先插入的位置一定要是上一条指令的结束,下一指令开始之前。还要随机选择出来一段是以上面开头的opcode
,所以我选择的方式是,按着字节码的结构解析出来指令的表,解析过程中,按照上面的表,分成列表。
这一段已经写成了代码,会贴在最后面。
你手里面有一段代码,然后把它混淆一下内容,获取到字节码序列化对象(利用marshal.dumps),获取到后,对字节码进行压缩,为了防止好辨认,再反转一下。
首先有个大家需要先知道的前提,就是python里面的函数,字符串什么的是放到常量表中的,并不是在代码段,我做的就是想把数据放到代码段,然后外面套个简单的壳子,写点伪代码描述一下:
t_code = "
import zlib
import marshal
def add(a,b):
return a+b
print "Run Func 'add' func_code"
exec (marshal.loads(zlib.decompress(add.func_code.co_code[::-1])))
"
t_insert_code = "
print("xdcfgvbhjnkjbhvgcf")
print("这里是测试要加密的代码")
"
t_code.co_consts[add] = zlib.compress(marshal.dumps(t_insert_code))[::-1]
差不多就是上面这个样子,第一段代码中的add函数就是个壳子,为的是让代码中可以不通过常量表访问数据,虽然这个add函数对象也在常量表中放着,但是我们对内容进行了压缩和反转,就算拿到了这个函数,打印出来也是下面这样子的:
<code object add at 0x10b8624b0, file "code_replace.py", line 8>
这样一段东西,打印出来还是个有名字的code,我觉得怀疑的几率不是很高吧~
感谢各位大佬看我废话这么久,最后还是要说一,即使已经做了这么多工,你的代码还是完全有可能被逆向,暂时还没有完美的防止逆向的方案,起码我现在还没想到,虽然现在我们可以进行简单的混淆欺骗一下机器,但是如果想欺骗人就很难了,因为代码是死的。
我们可能实际上需要的并不是为了防止反编译,我们实际上需要的是保护自己的代码,增加对抗的成本,让对方反编译你的代码感觉到头疼,并且付出大量的时间和精力。假如说你的代码里面插入了10个混淆指令,可能对方稍微费些心思就会完成逆向,如果你的字节码里面插入了和正常代码等比例的混淆代码呢?如果是两倍三倍的垃圾数据呢?这个过程需要耗费的精力就很大了,可能对方就对解密你的代码要放弃了。
对于混淆推荐的套路是,先代码混淆,然后字节码混淆一下,这样出来的代码,恐怕是少有人能看懂了,看得懂也要付出很大的精力才能进行还原。
最后贴一下最近两天才拼了个混淆的渣渣库:
https://github.com/c0cc/code_obfuscate