Python的字节码混淆

Python的字节码混淆

二月 20, 2019

借着写 Hgame 的 wp,写了点奇怪的东西。

Python 的字节码混淆

最近 CTF 比赛中偶尔会出现几个 Python 字节码混淆的题目。并且在今年的 Hgame 招新赛中我也放出了几道同类题目。

趁着要写 Writeup ,顺路把我了解的知识点整理成文章。如果有写错或者需要补充的地方,请师傅们指出。

Python Pyc 的文件格式解析

定义

先看一下 Pyc 的 PyCodeObject:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[compile.h]
/* Bytecode object */
typedef struct {
PyObject_HEAD
int co_argcount; /* #arguments, except *args */
int co_nlocals; /* #local variables */
int co_stacksize; /* #entries needed for evaluation stack */
int co_flags; /* CO_..., see below */
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 doesn't count for hash/cmp */
PyObject *co_filename; /* string (where it was loaded from) */
PyObject *co_name; /* string (name, for reference) */
int co_firstlineno; /* first source line number */
PyObject *co_lnotab; /* string (encoding addr<->lineno mapping) */
} PyCodeObject;

然后我们用实际的 pyc 文件理解一下.

1
print 'Helloworld'

将其编译为 Pyc 文件:

1
python -m hello.c

看一下 Pyc 文件的内容:

1
2
3
4
5
6
7
8
9
10
↪ hexdump -C hello.pyc
00000000 03 f3 0d 0a a4 6e 63 5c 63 00 00 00 00 00 00 00 |.....nc\c.......|
00000010 00 01 00 00 00 40 00 00 00 73 09 00 00 00 64 00 |.....@...s....d.|
00000020 00 47 48 64 01 00 53 28 02 00 00 00 74 0a 00 00 |.GHd..S(....t...|
00000030 00 48 65 6c 6c 6f 77 6f 72 6c 64 4e 28 00 00 00 |.HelloworldN(...|
00000040 00 28 00 00 00 00 28 00 00 00 00 28 00 00 00 00 |.(....(....(....|
00000050 73 08 00 00 00 68 65 6c 6c 6f 2e 70 79 74 08 00 |s....hello.pyt..|
00000060 00 00 3c 6d 6f 64 75 6c 65 3e 01 00 00 00 73 00 |..<module>....s.|
00000070 00 00 00 |...|
00000073

首先的四个字节是 MagicNumber , 接下来的四个字节是 时间戳 , 由于这里采用的是 小端序 , 所以我们得到的时间戳应该是 0x5C636EA4

然后后面就是 PyCodeObject

首先是 TYPE_CODE , 我们需要的是字符 , 所以是 C , 即0x63

然后后面四个字节是参数个数 co_argcount , 在后面四个字节是局部变量个数 co_nlocals , 接着四个字节是栈空间 co_stacksize , 最后是四个字节的 co_flags

co_code

接下来就是 co_code , 先是 TYPE_CODE , 用来做类型标识 , 这里是 s , 即 0x73

后面的四个字节用来标识 长度 , 这里是 0x09 .

我们实际的来验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> import dis,marshal
>>> f = open('hello.pyc')
>>> f.read(4)
'\x03\xf3\r\n'
>>> f.read(4)
'\xa4nc\\'
>>> code = marshal.load(f)
>>> code.co_argcount
0
>>> code.co_nlocals
0
>>> code.co_stacksize
1
>>> code.co_flags
64
>>> code.co_code
'd\x00\x00GHd\x01\x00S'
>>> len(code.co_code)
9

然后看一下 co_code 的具体内容:

1
2
3
4
5
6
7
8
9
10
11
12
>>> dis.dis(code)
1 0 LOAD_CONST 0 ('Helloworld')
3 PRINT_ITEM
4 PRINT_NEWLINE
5 LOAD_CONST 1 (None)
8 RETURN_VALUE
>>> code.co_consts
('Helloworld', None)
>>> code.co_names
()
>>> code.co_varnames
()

第一列数字是这个代码块在源码中的行数 , 第二列数字表示该指令在 co_code 中的偏移 , 第三列表示具体操作 , 第四列是操作数。 其中第三列的指令分为 有操作数无操作数 两种 , 指令占一个字节 , 操作数占两个字节。

关于以上 co_code 指令的具体作用比较明显 , 不做特殊说明。

这里给出两份文档 , 先者是对指令集的注释 , 后者是指令对应的字节码。

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

https://github.com/python/cpython/blob/master/Include/opcode.h

co_consts 及 其他

1
2
>>> code.co_consts
('Helloworld', None)

现在我们分析到了图片中 00000027h 的位置。 从这里开始是 code.co_consts 的内容 。 这里的 0x28TYPE_TUPLE , 即 ( .

接下来的四个字节为元素个数。 这里的 0x02 表示包含两个元素。

先写入 TYPE_INTERNED , 这里是 0x74 , 即 t , 然后写入 0x0a 长度的字符串 , 即 helloworld 。后面的 N 表示 None .

后面为 co_names , 标识 0x28 , 接着四个字节为元素个数 , 然后字符类型 , 字符内容。

接下来的 co_varnames , co_freevars , co_cellvars 结构与上面相同。

00000050h0x73 开始为 co_filename , 先标识类型 s , 然后路径长度为 ‘0x08’ , 路径为 ‘hello.py’ 。

然后是 co_name , 同样是标识类型 , 四字节长度 , 内容。

co_firstlineno ,这里为 0x01

最后是代表字节码指令与源文件行号对应关系的 co_lnotab , 同样是标识类型 , 四字节长度 , 内容。

至此,我们对这个 Pyc 文件的解析结束。这里只是很粗糙的介绍了一下 Pyc 文件的结构。

Python 的字节码混淆

一个不经过任何处理的 Pyc 文件 , 可以被https://tool.lu/pyc/直接反编译 , 得到 python 的源码。

在上面的文件格式解析中 , 我们知道可以通过 dismarshal 等工具 , 通过分析 Opcode来分析代码。

对抗 Uncompyle6 等工具

我们在 Pyc 文件的代码段头部添加如下指令 0x71 0x03 0x00 。(不要忘记修改 co_code 的长度)

使其变成:

1
2
3
4
5
6
7
8
9
10
↪ hexdump -C hello.pyc
00000000 03 f3 0d 0a a4 6e 63 5c 63 00 00 00 00 00 00 00 |.....nc\c.......|
00000010 00 01 00 00 00 40 00 00 00 73 0c 00 00 00 71 03 |.....@...s....q.|
00000020 00 64 00 00 47 48 64 01 00 53 28 02 00 00 00 74 |.d..GHd..S(....t|
00000030 0a 00 00 00 48 65 6c 6c 6f 77 6f 72 6c 64 4e 28 |....HelloworldN(|
00000040 00 00 00 00 28 00 00 00 00 28 00 00 00 00 28 00 |....(....(....(.|
00000050 00 00 00 73 08 00 00 00 68 65 6c 6c 6f 2e 70 79 |...s....hello.py|
00000060 74 08 00 00 00 3c 6d 6f 64 75 6c 65 3e 01 00 00 |t....<module>...|
00000070 00 73 00 00 00 00 |.s....|
00000076

这样 , Uncompyle6 或者 https://tool.lu/pyc/ 等工具就无法直接得到 python 的源码。

但是我们通过 dismarshal 仍然可以进行分析:

1
2
3
4
5
6
7
8
9
10
11
12
>>> import dis,marshal
>>> f = open('hello.pyc')
>>> f.read(8)
'\x03\xf3\r\n\xa4nc\\'
>>> code = marshal.load(f)
>>> dis.dis(code)
1 0 JUMP_ABSOLUTE 3
>> 3 LOAD_CONST 0 ('Helloworld')
6 PRINT_ITEM
7 PRINT_NEWLINE
8 LOAD_CONST 1 (None)
11 RETURN_VALUE

我们可以看出 , 我们添加的三个字节只是给代码加了一个绝对跳转 , 并没有影响代码流程。

对抗 dis

那么 , 如果我们让 dis 失效的话 , 就会更好的保护源码。

我们对照没有修改过的 Pyc 文件。 给其代码段头部添加如下指令: 0x71 0x00 0x06 0x64 0xff 0xff 。 同样记得修改 co_code 的长度。

使其变成:

1
2
3
4
5
6
7
8
9
10
↪ hexdump -C hello.pyc
00000000 03 f3 0d 0a a4 6e 63 5c 63 00 00 00 00 00 00 00 |.....nc\c.......|
00000010 00 01 00 00 00 40 00 00 00 73 0f 00 00 00 71 06 |.....@...s....q.|
00000020 00 64 ff ff 64 00 00 47 48 64 01 00 53 28 02 00 |.d..d..GHd..S(..|
00000030 00 00 74 0a 00 00 00 48 65 6c 6c 6f 77 6f 72 6c |..t....Helloworl|
00000040 64 4e 28 00 00 00 00 28 00 00 00 00 28 00 00 00 |dN(....(....(...|
00000050 00 28 00 00 00 00 73 08 00 00 00 68 65 6c 6c 6f |.(....s....hello|
00000060 2e 70 79 74 08 00 00 00 3c 6d 6f 64 75 6c 65 3e |.pyt....<module>|
00000070 01 00 00 00 73 00 00 00 00 |....s....|
00000079

接着我们用 dis 进行分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> import dis,marshal
>>> f = open('hello.pyc')
>>> f.read(8)
'\x03\xf3\r\n\xa4nc\\'
>>> code = marshal.load(f)
>>> dis.dis(code)
1 0 JUMP_ABSOLUTE 6
3 LOAD_CONST 65535
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/local/Cellar/python@2/2.7.15_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/dis.py", line 43, in dis
disassemble(x)
File "/usr/local/Cellar/python@2/2.7.15_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/dis.py", line 95, in disassemble
print '(' + repr(co.co_consts[oparg]) + ')',
IndexError: tuple index out of range

dis 抛出了异常 。

我们可以分析一下我们添加的代码:(手动还原)

1
2
3
1           0 JUMP_ABSOLUTE            6
3 LOAD_CONST 65535
>> 6 LOAD_CONST 0 ('Helloworld')

先是绝对跳转到偏移为 6 的位置 , 读取第 65535 项常量。

因为不存在第 65535 项常量 , 所以dis 抛出了异常。

但是由于绝对跳转的存在 ,第二句指令并没有被执行。

dis 会逐句进行翻译 , 并不会根据代码流程进行翻译。 所以当第二句指令报错时 , 停止了对后面指令的处理。

重叠指令

重叠指令不会使反编译工具失效 , 但是增加代码分析难度。

简单举例:

1
2
3
4
0 JUMP_ABSOLUTE        [71 05 00]     5 
3 PRINT_ITEM [47 -- --]
4 LOAD_CONST [64 64 01] 356
7 STOP_CODE [00 -- --]

第一句的绝对跳转将跳转到偏移为 05 的位置。 PRINT_ITEM 为无操作数指令 。 所以此时读到的操作数为 64 , 即 LOAD_CONST , 是有操作数指令。 因此跳转之后执行的指令为 [64 01 00]。即上述的四个指令实际执行的操作为 :

1
LOAD_CONST              1

类似的 , 还有通过多个跳转来实现更多指令。

私有指令集

Python 的 Bytecode 有 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[opcode.h]

#define STOP_CODE 0

#define POP_TOP 1

#define ROT_TWO 2

……

#define CALL_FUNCTION_KW 141

#define CALL_FUNCTION_VAR_KW 142

#define EXTENDED_ARG 143

如果在规则范围内修改了上面的定义 , 所生成的 Pyc 文件 , 只有与其指令集相同的目标才能运行。

具体可以参考 0CTF 2017

https://processor.pub/2017/03/22/0CTF-2017-python%E9%80%86%E5%90%91/

小结

个人认为 Python 的字节码混淆的技术可以和32位汇编相结合。

以上如果有不准确的地方 , 欢迎师傅们指出。