众所周知,Python是一种开源的解释型语言,这使其难以保护源码,但由于恶意软件编写、商业软件发布、关键数据保护等不同的需求,逐渐发展出了各种各样的保护Python代码的方法。本文将从源码、字节码及可执行文件三个层面来大致介绍这些保护方法及其对应的破解,这几个层面在实现和破解上的难度是逐步递增的。
源码层面
01
Oxyry Python Obfuscator
Oxyry Python Obfuscator [1] 是一个针对源码的在线混淆器,支持Python 3.3-3.7,它在处理原代码时会将符号名称(包括变量、函数、类、参数、类私有方法)进行重命名,同时避免了明文名称到混淆名称的1:1映射,相同的名称可能会在不同的范围内转换为多个不同的名称。
可以看出混淆之后的代码里变量名是几乎不可区分的,确实能起到阻止变量跟踪的效果,但不多。在这种混淆下,可以使用一些具有相同文本跟踪功能的编辑器(如sublime)进行变量的跟踪或者在同一个函数下的更名来达到反混淆的效果。
02
pyminifier
Pyminifier [2] 是一个Python代码的压缩器和混淆器,在这里只介绍它的混淆功能。
可以看到混淆前后函数结构没有太大的变化,只是将函数名和变量名进行了替换,对应的替换直接写在了除导入语句以外的最前面。对于此类混淆,方法还是跟前者一样,对代码文本进行全局替换,并将前面的语句注释掉即可。
03
Python Source Obfuscation using ASTs
这是Jurriaan Bremer在2013年的HITB CTF上为了出题而创造的一种混淆方法 [3] ,这种方法的核心思想在于将Python代码解析为抽象语法树(AST),并将其重写,最后通过重写的AST编写出新的Python源代码。
混淆效果看起来还算不错,在对部分变量名更名的同时,将常数拆成了简单算式、字符串拆成了片段或加以反转、将导入模块动态载入。不过这些混淆都可以通过Python解释器重新运行来进行简化,比如:
可能是因为工具比较古早的问题,在具体运行上有一些数据不够准确,但其思想仍然对后续研究有一定的启发作用。
字节码层面
01
字节码文件(.pyc)
pyc文件头
文件头目前来说一共有三个不同的格式。
在Python 2里,文件头长度为8字节,其中有4字节的magic int,用以标识版本信息,通常为2字节版本magic word+b’x0dx0a’;还有4字节的timestamp,即Unix Timestamp,从UTC1970年1月1日0时0分0秒起至现在的总秒数,不考虑闰秒。
在Python 3.0-3.6中,文件头长度为12字节,其中除了上文中的4字节magic int及4字节timestamp以外,还追加了4字节source size,用小端序标识源码文件大小。
在Python 3.7以后,文件头长度为16字节,在4字节magic int后面增加了4字节的hash checked flag,用小端序记录,标识pyc文件是否启用hash校验,为0则不启用,为1(UNCHECKED_HASH,默认最新不检查)或3(CHECKED_HASH)则启用,启用以后原来的4字节timestamp+4字节source size会被8字节的siphash13哈希值覆盖。
以上4字节的magic word可以在cpython库中的/Lib/importlib/_bootstrap_external.py路径下找到各版本对应的16bits整数。
pyc文件由文件头+序列化的PyCodeObject对象组成,在Python/marshal.c中可以追溯到其序列方式。
首先所有的PyObject都会用一个1字节存储TYPE,PyCodeObject使用TYPE_CODE,其余TYPE如下图所示。
-
co_argcount:需要的位置参数个数,不包括变长参数(*args 和 **kwargs)
-
co_kwonlyargcount:不定参数后定义的位置参数个数
-
co_nlocals:所有的局部变量的个数,包括所有参数
-
co_stacksize:运行时所需要的最大栈深度
-
co_flags:在 code.h 里定义
-
co_code:PyStringObject,代码对应的字节码
-
co_consts:PyTupleObject,所有常量
-
co_names:PyTupleObject,所用的到符号
-
co_varnames:PyTupleObject,所用到的局部变量名
-
co_freevars:PyTupleObject,所用到的freevar变量名
-
co_cellvars:PyTupleObject,所用到的cellvar变量名
-
co_filename:PyStringObject,对应的python文件
-
co_name:该PyCodeObject的名称
-
co_firstlineno:该PyCodeObject对应的源文件首行行号
-
co_lnotab:PyStringObject,字节码偏移量与源码行号的对应关系
其中存储代码内容的为co_code,顺序存储了代码中的字节码指令。在Python3.6之前使用变长指令(1字节或3字节),第1字节是操作码opcode,如果opcode有参数,则会多加2字节参数(小端序);而在Python3.6及之后使用定长指令(2字节),第1字节同样是opcode,第2字节是参数,如无参数则默认为b’x00’。不同版本的opcode不同,详细见各版本源码的Include/opcode.h,也可以用以下脚本打印当前版本的opcode。
import opcode
for i, j in enumerate(opcode.opname):
print("0x%02x(%03d): %s" % (i, i, j))
02
pyc隐写
想要在pyc中隐藏信息,可以利用Python 3.6及以上的版本中字节码恒为2字节、无参opcode默认b’x00’的特性,在这些冗余的b’x00’字节上逐个乱序嵌入payload的各个字节,现有工具Stegosaurus [4] 完成了这一实现。
该工具同时内置了隐写和提取接口,当遇到用Stegosaurus隐写的pyc时,可以用其提取出隐写信息。
原版工具仅支持3.6,笔者做了一个对3.7+版本的支持,开源在:https://github.com/c10udlnk/stegosaurus。
03
pyc反编译
pycdc [6] 是一个集成了Python反编译器及反汇编器的工具,其反编译效果同样出色,笔者个人感觉包容性比uncompyle6稍微优秀一点。
04
pyc反反编译
因为pyc反编译技术已然成熟,出现了针对这些反编译的进一步保护技术,目前常见的有两种混淆方式,从原理上说与二进制文件的反编译抵抗技术有些类似。
在遇到不能反编译的二进制文件时,我们会通过查看其汇编代码来分析其阻碍反编译的原因(比如一些花指令)。同样的,在pyc文件不能被反编译时,我们也可以通过pycdc的分支pycdas对pyc文件进行“反汇编”来协助我们进行分析。后文在破解反反编译技术时,正是依赖于这样类似于汇编的Python字节码。
这种方法的核心原理是通过JUMP_ABSOLUTE跳过无意义字节,但无意义字节仍会被反汇编器处理。这样会在反编译器对pyc进行反汇编时解析不到无意义字节的opcode或者参数(如超出了LOAD_CONST的索引)而导致报错。已有工具实现这样的混淆:pyc_obscure [7] 。
在实际实验下来发现,pyc_obscure工具在混淆时没有修改原代码中跳转指令的偏移,导致混淆以后不能正常运行(报错segmentation fault)。
为了使实验继续进行,笔者将该工具进行了修复,在阻止反编译的同时正常运行pyc,开源在https://github.com/c10udlnk/pyc_obscure。
针对这种混淆,破解方法是将这些语句转化为b’x09x00x09x00’(即两个nop语句),这样修复能保证代码的长度不变,且不会影响原代码中跳转指令的偏移。
修改以后即可使用pycdc正常反编译。
在二进制文件的花指令中,有一种很常用的Overlapping Instruction。在使用变长指令的指令系统中,相同的字节序列可能会被处理器解释为完全不同的指令,具体取决于执行开始的确切字节。
Python字节码中同样存在这样的花指令(在Python 3.6以前使用变长指令),目前来说还没有实现这种效果的工具,这里笔者写了一个小脚本加以说明。
可以看到这样就能实现跟在二进制中同样的干扰功能,并且程序能正常运行。那么破解方法其实就也是跟二进制一样,将多余字节(图中标红的部分)全部用b’x09’(NOP)覆盖,这样就能避免干扰,达到正常反编译的目的。
可执行文件层面
01
Pyinstaller打包
02
定制Python解释器
笔者按照其流程改造了一个这样的Python解释器。可能是由于该分享比较古早,在文章中有提到“由于 Python 解释器本身是二进制文件,所以不需要担心内置的私钥会被看到”,且实现上也没有对私钥做特别的保护,在实际破解中可以发现以当前的逆向分析技术可以很轻松地获取到私钥,利用私钥对加密密钥进行解密,进而解密源码。
03
Pyarmor
Pyarmor [10] 是一个加密Python代码并控制代码可运行期限的商业工具,使用Python的C拓展库将自己的加解密函数加入到内置函数库中,兼容Python各版本运行。其加密后的文件结构如下:
其中__init__.py和_pytransform动态库为辅助运行库,foo.py为加密后的代码。
可以看到确实很好地隐藏了源码。该工具有两层加密,一是在最开始运行时对整个二进制串进行解密加载成代码对象并执行,二是在函数运行前会进行第二次解密,在函数运行结束后加密,保证攻击者不能从内存中dump出解密后的函数。
因许可限制,这里不对其二进制辅助库作详细分析,对加密脚本的详解可以参考官方文档:https://pyarmor.readthedocs.io/zh/latest/topic/obfuscated-script.html
[1] https://pyob.oxyry.com
[2] https://github.com/liftoff/pyminifier
[3] http://jbremer.org/python-source-obfuscation-using-asts
[4] https://github.com/AngelKitty/stegosaurus
[5] https://github.com/rocky/python-uncompyle6
[6] https://github.com/zrax/pycdc
[7] https://github.com/marryjianjian/pyc_obscure
[8] https://github.com/extremecoders-re/pyinstxtractor
[9] https://segmentfault.com/a/1190000021660914
[10] https://github.com/dashingsoft/pyarmor
原文始发于微信公众号(山石网科安全技术研究院):Python代码保护技术及其破解
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论