Py学习  »  Python

Python代码保护 | pyc 混淆从入门到工具实现

长亭安全课堂 • 3 年前 • 507 次点击  

之前接触到 Python 逆向相关的一些 CTF 题目(最近一次是某符的 game),有的给出 Python 的伪指令,还有的直接给了一个被替换过指令的 pyc 文件,于是学习了一下Python 的字节码。学习过程中发现替换字节码指令这个操作其实是 Python 源码保护的一种方式,于是想到有没有不去修改 Python 解释器的方法去保护源码(增加对抗的成本)。


查阅资料发现 Python 源码有几种保护的方式:


  1. 生成 pyc 文件

    这感觉完全不能算保护,uncompyle6 一键反编译,支持 Python 1.0 到 3.8 全部版本(恐怖)

  2. py 源码混淆

    一般针对 py 源码混淆就是往代码里插入一些没有意义跳转分支,修改变量名和函数名等这些操作,但是这种虽然阅读起来很难理解,但是混淆效果并不好。

  3. 打包成可执行的二进制文件

  4. 自定义 opcode 的 Python 解释器


学习了 Python 字节码之后,就想从 pyc 文件入手,去做一些混淆,因为虽然 uncompyle6 使得 pyc 文件反编译变得很简单,但是简单的无效指令就可能使这类工具失效。


查阅资料过程中发现其实针对 Python2 的字节码有较多的分析,但是针对 Python3 字节码分析就几乎没有了;虽说原理的都是一样的,但是指令和格式上 Python3 都有了一定的变化,并且其实Python3 不同版本之间的变化也是较大的,所以下面先对 pyc 格式进行简单的版本对比分析,然后再谈谈混淆的思路。


01
生成 pyc 文件

pyc 文件其实包含的是 Python 虚拟机可执行的的 byte-code。


Python 自带的 py_compile 模块可以直接把源码编译成 pyc 文件,我们平时的的模块导入(import)也是会将导入的模块对应的文件编译成 pyc 的。


02
 pyc 文件的格式


magic number + 源代码文件信息 + PyCodeObject


  • 4个字节的 magic number

  • 12个字节的源代码文件信息(不同版本的 Python 包含的长度和信息都不一样,后面说)

  • 序列化之后的 PyCodeObject


magic number

像大多数的文件格式一样,pyc 文件开头也有一个 magic number,不过不一样的是 pyc 文件的 magic number 并不固定,而是不同版本的 Python 生成的 pyc 文件的 magic number 都不相同。这里可以看到看不同版本的 Python 的 magic number 是多少。前两个字节以小端的形式写入,然后加上 \r\n 形成了四个字节的 pyc 文件的magic number

如 Python2.7 的 magic number 为 MAGIC_NUMBER = (62211).to_bytes(2, 'little') + b'\r\n'


我们可以看到的前四个字节的16进制形式为 03f3 0d0a


python 2.7生成的 pyc 文件前32个字节



源代码文件信息

源代码文件信息在 Python 不同的版本之后差别较大


  • 在Python2的时候,这部分只有4个字节,为源代码文件的修改时间的 Unix timestamp(精确到秒)以小端法写入,如上图 (1586087865).to_bytes(4, 'little').hex() -> b9c7 895e。

  • 在 Python 3.5 之前的版本已经找不到了(后面就都从 Python 3.5 开始讨论了)

  • Python 3.5 和 3.6 相对于 Python 2,源代码文件信息这部分,在时间后面增加了4个字节的源代码文件的大小,单位字节,以小端法写入。如源码文件大小为87个字节,那么文件信息部分就写入 5700 0000。加上前面的修改时间,就是 b9c7 895e 5700 0000

    python 3.6生成的 pyc 文件前32个字节

  • 从 Python3.7 开始支持 hash-based pyc 文件

Changed in version 3.7: Added hash-based .pyc files. Previously, Python only supported timestamp-based invalidation of bytecode caches.


也是就说,Python 不仅支持校验 timestrap 来判断文件是否修改过了,也支持校验 hash 值。Python 为了支持 hash 校验又使源代码文件信息这部分增加了4个字节,变为一共12个字节。


python 3.7生成的 pyc 文件前32个字节


但是这个 hash 校验默认是不启用的(可以通过调用 py_compile 模块的 compile 函数时传入参数invalidation_mode=PycInvalidationMode.CHECKED_HASH 启用)。不启用时前4个字节为0000 0000,后8个字节为3.6和3.7版本一样的源码文件的修改时间和大小;当启用时前4个字节变为0100 0000或者0300 0000,后8个字节为源码文件的 hash 值。



PyCodeObject


其实这是一个定义在 Python 源码 Include/code.h 中的结构体,结构体中的数据通过 Python 的 marshal 模块序列化之后存到了 pyc文件当中。(不同版本之间 PyCodeObject 的内容是不一样的,但是这就导致了不同版本之间的 Python 产生的 pyc 文件其实并不完全通用,以下举例均使用 python 3.7)


marshal 模块中实现了一些基本的 Python 对象(也就是 PyObject )的序列化,一个 PyObject 序列化时首先会写入一个字节表示这是一个什么类型的 PyObject,不同类型的 PyObject 对应的类型如下,PyCodeObject 对应的就是 TYPE_CODE,写入第一个字节就是63。


// Python/marshal.c

// ......

#define TYPE_NULL               '0'

#define TYPE_NONE               'N'

#define TYPE_FALSE              'F'

#define TYPE_TRUE               'T'

#define TYPE_STOPITER           'S'

#define TYPE_ELLIPSIS           '.'

#define TYPE_INT                'i'

/* TYPE_INT64 is not generated anymore.

   Supported for backward compatibility only. */

#define TYPE_INT64              'I'

#define TYPE_FLOAT              'f'

#define TYPE_BINARY_FLOAT       'g'

#define TYPE_COMPLEX            'x'

#define TYPE_BINARY_COMPLEX     'y'

#define TYPE_LONG               'l'

#define TYPE_STRING             's'

#define TYPE_INTERNED           't'

#define TYPE_REF                'r'

#define TYPE_TUPLE              '('

#define TYPE_LIST               '['

#define TYPE_DICT               '{'

#define TYPE_CODE               'c'

#define TYPE_UNICODE            'u'

#define TYPE_UNKNOWN            '?'

#define TYPE_SET                '

#define TYPE_FROZENSET          '>'

#define FLAG_REF                '\x80' /* with a type, add obj to index */


// 以下都是Python3.5之后支持的

#define TYPE_ASCII              'a'

# define TYPE_ASCII_INTERNED     'A'

#define TYPE_SMALL_TUPLE        ')'

#define TYPE_SHORT_ASCII        'z'

#define TYPE_SHORT_ASCII_INTERNED 'Z'

// ......


python 3.7生成的 pyc 文件前32个字节


但是我们发现我们第17个字节也就是 PyCodeObject 的第一个字节却是 0xe3,这是因为 PyObject 对象第一个字节还可以有一个 flag(# define FLAG_REF '\x80'),即第一个字节为0x63 | 0x80 -> 0xe3。( FLAG_REF 表示将这个对象加入引用列表,当下次再出现这个对象的实现就可以不用再序列化一遍这个对象,直接使用 TYPE_REF 取这个对象就可以了;算是 Python 序列化的一种优化吧。Python2 实现不同。)


/* Bytecode object */

typedef struct {

    PyObject_HEAD

    int co_argcount;            /* #arguments, except *args */

    int co_posonlyargcount;     /* #positional only arguments */

    int co_kwonlyargcount;      /* #keyword only arguments */

    int co_nlocals;             /* #local variables */

    int co_stacksize;           /* #entries needed for evaluation stack */

    int co_flags;               /* CO_..., see below */

    int co_firstlineno;         /* first source line number */

    PyObject *co_code;          /* instruction opcodes */

    PyObject *co_consts;        /* list (constants used) */

    PyObject *co_names;         /* list of strings (names used) */

    PyObject *co_varnames;      /* tuple of strings (local variable names) */

    PyObject *co_freevars;      /* tuple of strings (free variable names) */

    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */

    /* The rest aren't used in either hash or comparisons, except for co_name,

       used in both. This is done to preserve the name and line number

       for tracebacks and debuggers; otherwise, constant de-duplication

       would collapse identical functions/lambdas defined on different lines.

    */

    Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */

    PyObject *co_filename;      /* unicode (where it was loaded from) */

    PyObject *co_name;          /* unicode (name, for reference) */

    PyObject *co_lnotab;        /* string (encoding addrlineno mapping) See

                                   Objects/lnotab_notes.txt for details. */

//  ......

} PyCodeObject;


上面结构体中的内容也不是全部都要写入 pyc,我们可以看看 marshal 序列化 PyCodeObject 的实现部分


// ......

    else if (PyCode_Check(v)) {

        PyCodeObject *co = (PyCodeObject *)v;

        W_TYPE(TYPE_CODE, p);

        w_long(co->co_argcount, p);

        w_long(co->co_kwonlyargcount, p);

        w_long(co->co_nlocals, p);

        w_long(co->co_stacksize, p);

        w_long(co->co_flags, p);

        w_object(co->co_code, p);

        w_object(co->co_consts, p);

        w_object(co->co_names, p);

        w_object(co->co_varnames, p);

        w_object(co->co_freevars, p);

        w_object(co->co_cellvars, p);

        w_object(co->co_filename, p);

        w_object(co->co_name, p);

        w_long(co->co_firstlineno, p);

        w_object(co->co_lnotab, p);

    }

// ......


上面代码我们可以发现 PyCodeObject 里面序列化了哪些字段和序列化的顺序。


第一个字节是 PyObject 的类型,然后是 5x4=20个字节的我们目前不大关心的信息。然后 PyCodeObject 的第22个字节开始就是 Python 的 opcode 序列了,这部分是决定了程序的执行流程,也就是我们最关心的部分了。由于一个 PyObject 的长度是不一定的,所以需要读取完一个对象才能继续读取下个对象。


简单说一下 PyCodeObject 中的几个 PyObject 序列化时采用的类型。


TYPE_STRINGTYPE_TUPLE后面的四个字节表示该对象的长度(小端表示),TYPE_STRING表示字符串的长度, TYPE_TUPLE表示tuple中对象的个数。


03
PyCodeObject 中的 co_code 


Python 的 opcode 序列决定了程序的执行流程,它被作为 TYPE_STRING 类型的 PyObject 存到了 PyCodeObject 的 co_code 当中。


python 3.7 的 opcode 序列


上图红框中的内容就是序列化之后的 opcode 序列了( offset 0x2a-0x47),第25个字节73表示 TYPE_STRING,第26-29个字节表示对象的长度,1e00 0000就是小端表示的30。

opcode

Python 的源码 Include/opcode.h 中定义了一系列的 opcode。其中,以 HAVE_ARGUMENT 为界限,凡是大于 HAVE_ARGUMENT 的 opcode 都是有参数的,凡是小于 HAVE_ARGUMENT 的 opcode 都是没有参数的(有参数的也只有一个参数)。

CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter. No guarantees are made that bytecode will not be added, removed, or changed between versions of Python. Use of this module should not be considered to work across Python VMs or Python releases.


Python并不保证不同的Python版本之间的opcode的兼容性,这也是Python各个版本之间的pyc不兼容的一个原因。


Changed in version 3.6: Use 2 bytes for each instruction. Previously the number of bytes varied by instruction.。


从 Python 3.6开始,有一个较大的改变,就是不管 opcode 有没有参数,每一条指令的长度都两个字节,opcode 占一个字节,如果这个 opcode 是有参数的,那么另外一个字节就表示参数;如果这个 opcode 没有参数,那么另外一个字节就会被忽略掉,一般都为00(猜测这样对方便对指令执行进行一些优化)。其实 opcode 的参数只是一个 offset。


但是在Python3.6之前,对于有参数的opcode,指令长度为3个字节,|opcode|argv_low|argv_high|,opcode 一个字节,参数两个字节也采用小端,如 Python 2.7中指令6401 00,表示 opcode 为 LOAD_CONST,参数为1


LOAD_CONST(consti)
Pushes co_consts[consti] onto the stack.


即从 co_consts 这个 tuple 对象中取出第1个对象(从0开始计算的,所以第一个就是co_consts[1])压到栈顶。


我们可以用 Python 自带的 dis 和 marshal 库帮助我们看一下 opcode 序列是怎么样的。


针对源码


print('Hello, world')


def fff(a, b):

    c = a + b

    return c & 0xffff


fff(34, 67)

用不同版本的 Python 产生 pyc 文件看一下 opcode。

Python2.7


>>> import dis, marshal

>>> f=open('t.pyc', 'rb').read()

>>> co=marshal.loads(f[8:]) # Python2.7中,PyCodeObjectpyc文件中的偏移为8

>>> dis.dis(co)

  1           0 LOAD_CONST           0 ('Hello, world')

              3 PRINT_ITEM

              4 PRINT_NEWLINE


  3           5 LOAD_CONST               1 ("t.py", line 3>)

              8 MAKE_FUNCTION            0

             11 STORE_NAME               0 (fff)


  7          14 LOAD_NAME                0 (fff)

             17 LOAD_CONST               2 (34)

             20 LOAD_CONST               3 (67)

             23 CALL_FUNCTION            2

             26 POP_TOP

             27 LOAD_CONST               4 (None)

             30 RETURN_VALUE

>>> co.co_names

('fff',)

>>> co.co_consts

('Hello, world', ".../t.py", line 3>, 34, 67, None)

Python3.7


>>> import dis, marshal

>>> f=open('t.pyc', 'rb').read()

>>> co=marshal.loads(f[16:]) # Python3.7中,PyCodeObjectpyc文件中的偏移为16

>>> dis.dis(co)

  1           0 LOAD_NAME                0 (print)

              2 LOAD_CONST               0 ('Hello, world')

              4 CALL_FUNCTION            1

              6 POP_TOP


  3           8 LOAD_CONST               1 ()

             10 LOAD_CONST               2 ('fff')

             12 MAKE_FUNCTION            0

             14 STORE_NAME               1 (fff)


  7          16 LOAD_NAME                1 (fff)

             18 LOAD_CONST               3 (34)

             20 LOAD_CONST               4 (67)

             22 CALL_FUNCTION            2

             24 POP_TOP

             26 LOAD_CONST               5 (None)

             28 RETURN_VALUE

>>> co.co_names

('print', 'fff')

>>> co.co_name

''

>>> co.co_consts

('Hello, world', ".../t.py", line 3>, 'fff', 34, 67, None)


04
pyc 混淆


思路


由于 pyc 文件有现成的工具( uncompyle 6 )可以还原成 Python 代码,所以说我们不了解 pyc 格式也没有关系。这样我们混淆 pyc 的思路就可以是欺骗像 uncompyle 6 这类反编译的工具,让它误以为指令的序列不合法,但是又不影响真正的Python 虚拟机执行。


Python 的虚拟机是根据 PyCodeObject 中的 co_code 这个字段中存储的 opcode 序列来决定程序的执行流程的。所以说一个混淆的手段就是修改 co_code 字段中的 opcode 序列,可以添加一些加载超出范围的变量的指令,再用一些指令去跳过这些会出错的指令,这样执行的时候就不会出错了,但是反编译工具就不能正常工作了。


举个简单的例子


0 LOAD_NAME                0 (print)

2 LOAD_CONST               0 ('Hello, world')

4 CALL_FUNCTION            1

6 POP_TOP


这是我们上面 Python 3.7生成的 pyc 的一段 opcode 序列,考虑在它的前面加两条指令。


0 JUMP_ABSOLUTE            4

2 LOAD_CONST               255

4 LOAD_NAME                0 (print)

6 LOAD_CONST               0 ('Hello, world')

8 CALL_FUNCTION            1

10 POP_TOP


其中 JUMP_ABSOLUTE 4 表示直接跳转到 offset 为4的位置去执行指令,也就是插入的第二条指令 LOAD_CONST 255 并不会被执行,所以所以也并不会报错。但是对于反编译工具来说,这就是一个错误了,直接导致了反编译的失败。

实现


根据上面的那个思路,我们可以插入许多这样类似的指令,任意的不合法指令(其实随机数据都可以),然后用一些 JUMP 指令去跳过这样的不合法指令,上面的 JUMP_ABSOLUTE 只是一个简单的例子。甚至我们可以跳转到一些自行添加的虚假分支再跳转到到真实的分支(参考 ROP 的思路)。


Python 的 opcode 中 JUMP 相关的有


'JUMP_FORWARD',

'JUMP_IF_FALSE_OR_POP',

'JUMP_IF_TRUE_OR_POP',

'JUMP_ABSOLUTE',

'POP_JUMP_IF_FALSE',

'POP_JUMP_IF_TRUE',


原则上这六个都可以使用,但是实际上为了方便的话,其实还是 JUMP_FORWARD 和 JUMP_ABSOLUTE 比较好用(字面理解,一个是相对跳转,一个是绝对跳转),因为其他的 JUMP 指令存在一些当前栈顶元素判断的问题(要做也可以,只不过实现同样的功能可能需要写更多的指令)。


还有一些在添加混淆指令的时候可能会遇到的问题:

  • 首先是 Python 版本的问题,前面说了,Python 3.6之前使用的是变长指令,3.6及之后都是用的是定长指令了,这样对于不同的版本需要有不用的处理。 对于变长指令,在查找资料是还发现了还可以使用重叠指令来混淆(参考最后 reference 中的博客链接),亲测也是一种有效的混淆方式。对于定长指令,上面说的重叠指令就没有办法了。

  • 由于添加了指令,一些原本存在的绝对跳转指令就会失效,所以需要对原本存在的绝对跳转指令计算偏移。

  • 对于相对跳转,由于参数只能非负,所以不能向前跳转;而绝对跳转只要计算好偏移就可以任意跳转了。

  • 不定长指令的参数长度是两个字节,而定长指令的参数只有一个字节,可能存在参数长度不够用的时候,这个时候可以使用 EXTENDED_ARG 指令去扩展参数的长度,最多可以有四个字节。

  • 跳转的混淆最好还是不要从循环内到循环外或者循环外到循环内。其实最好是根据 co_lnotab 字段中的指令偏移和行号来插入混淆指令,不在属于同一行的指令中间插入,这样可以避免一些可能存在的问题。


添加的混淆指令越多,文件的体积也就越大了,但是混淆的效果可能也会更好一点。


根据上面的思路,我简单的糊了一个简陋的 pyc 混淆器。



真·糊出来的:https://github.com/marryjianjian/pyc_obscure


这个工具按照上面说的思路实现了一个基本的 pyc 混淆器,可以用来对不太复杂的 Python 代码生成的 pyc 文件进行简单的混淆,混淆后的 pyc 文件,用现在的 Python 反编译器均无法正常的反编译。


05
End


其实学习了 pyc 不同版本之间的差异之后,发现混淆 pyc 这种方式其实还是存在版本局限性的,因为 Python 自己都不能保证不同小版本之间生成的 pyc 文件是可以相互执行的,所以还是只针对某一单一的版本进行混淆更容易一些。通过对 opcode 的进行混淆来达到反编译器失效但是 pyc 仍可以执行这样的方式,其实也只能欺骗一下反编译器,不能直接把 pyc文件反编译成 py 文件,这样的混淆如果搭配上源码的混淆感觉效果会更好一些。想要完全避免 Python 代码被分析几乎是不可能的,但是通过简单的混淆能大大增加分析者的工作量,我们的目的就达到了。


06
reference


https://kdr2.com/tech/main/1012-pyc-format.html

https://github.com/python/cpython

https://docs.python.org/3.7/library/dis.html

https://blog.csdn.net/ir0nf1st/article/details/61650984

https://www.secpulse.com/archives/106129.html


Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/64182
 
507 次点击