之前接触到 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 格式进行简单的版本对比分析,然后再谈谈混淆的思路。
生成 pyc 文件
pyc 文件其实包含的是 Python 虚拟机可执行的的 byte-code。
Python 自带的 py_compile 模块可以直接把源码编译成 pyc 文件,我们平时的的模块导入(import)也是会将导入的模块对应的文件编译成 pyc 的。
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; /