pyc文件的分析

参考blogs:CataLpa师傅的https://wzt.ac.cn/2019/02/13/pyc-simple/

0x00 pyc简介

​ 某些情况下,反编译 pyc 可能会失败,造成失败的原因有很多,最常见的就是将 pyc 中的结构、byte-code或者一些逻辑进行修改和混淆,甚至会修改 python 的源代码来自定义 opcode

​ 实际上, pyc 文件所存储的主体就是 python byte-code 另外还有一些必要的结构。为什么会存在 pyc 文件呢?这就回到了之前的问题上,即 python 运行速度慢,由于计算机无法理解高级语言,我们写的代码必须先被编译成计算机能识别的机器码才能被执行,python 也是一样,不过开发者在机器底层和源代码之间加了一层虚拟机,将许多底层硬件细节进行了封装和屏蔽,使得程序员可以专注于自己的代码逻辑上面,这样也造成了一些弊端,python 程序通常以 .py 为后缀名,其内容就是开发者所编写的源代码,所以,每次运行程序的时候,都需要先编译再执行,当项目代码成千上万行时,如果每次运行都需要编译,那么效率可想而知。

​ 为了解决这个问题。python 的开发者提出了一个很好的解决方案,将一个 python 程序会使用到的模块先编译成 pyc 文件,之后再调用的时候,即可省去编译的时间,提高程序效率。这里的 pyc 文件实际上就是 python 模块的预编译文件

0x01 pyc格式解析

​ 生成pyc文件python -m xxx.py

​ 例子:自己写了一个test.py

1
2
def hello():
print("hello")

​ 转成pyc并使用010editor打开,得到:

image-20221202134522477

​ 010editor解析如下:

image-20221202140155891

前四个字节是pyc的magic value,前两个字节是可变的,它和编译 python 文件的 python 版本有关,接下来两个字节是固定的 0D0A,转换成 ASC 码就是 \r\n,所以如果一个 pyc 文件被以文本形式打开复制到另一个文件中,新文件一般是不会正常工作的,这也是 pyc 的一种简单保护手段。

接下来四个字节是char mtime,它也占据 4 个字节。这个字段表示该 pyc 文件的编译日期,用 unix 时间戳来表示,由于字节的小端序,要反过来看,例如我这里的文件时间戳是 63898EC5,那么转换成真正的时间就是 2022/12/2 13:36:5。

然后就是 pyc 文件的主体部分了,通常叫做struct r_object data,打开之后里面有很多内容。首先是 enum ObjType type(CODE_CODE),占 1 个字节,用它来表示一个 PyCodeObject 开始了。

0x02 PyCodeObject解析

​ PyCodeObject 包含许多小的组成成部分,这些小部分称为 PyObject。

​ PyObject 第一个字节指明了接下来的内容是什么类型,例如 0x63 就表示后面跟着的是 CODE_CODE,或者 0x28 就表示后面跟着的是常量列表等等,这里有一份定义类型的源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//Python/marshal.c:22
#define TYPE_NULL '0' '0x30'
#define TYPE_NONE 'N' '0x4E'
#define TYPE_FALSE 'F' '0x46'
#define TYPE_TRUE 'T' '0x54'
#define TYPE_STOPITER 'S' '0x53'
#define TYPE_ELLIPSIS '.' '0x2E'
#define TYPE_INT 'i' '0x69'
#define TYPE_INT64 'I' '0x49'
#define TYPE_FLOAT 'f' '0x66'
#define TYPE_BINARY_FLOAT 'g' '0x67'
#define TYPE_COMPLEX 'x' '0x78'
#define TYPE_BINARY_COMPLEX 'y' '0x79'
#define TYPE_LONG 'l' '0x6C'
#define TYPE_STRING 's' '0x73'
#define TYPE_INTERNED 't' '0x74'
#define TYPE_STRINGREF 'R' '0x52'
#define TYPE_TUPLE '(' '0x28'
#define TYPE_LIST '[' '0x5B'
#define TYPE_DICT '{' '0x7B'
#define TYPE_CODE 'c' '0x63'
#define TYPE_UNICODE 'u' '0x75'
#define TYPE_UNKNOWN '?' '0x3F'
#define TYPE_SET '<' '0x3C'
#define TYPE_FROZENSET '>' '0x3E'

​ 除了 CODE_CODE,还有其他的字段:

1
2
3
4
argcount  参数个数
nlocals 局部变量个数
stacksize 栈空间大小
flags N/A

​ 我们主要关注字节码,字节码类似于机器码,可以通过一定的手段将它们转换成类似于汇编语言的可读代码,这里我们需要用到 python 自带的模块 dis

​ 编写如下脚本:

1
2
3
4
5
6
import dis

pyc = open("test.pyc", "rb")
pyc.read(30)
target = pyc.read(0x31)
dis.dis(target)

​ 上述脚本主要是分析struct Code code中的字节码:

image-20221202142238532

​ 输出为:(从左到右分为四列,第一列代表字节偏移量,第二列是指令操作码的含义,第三列是操作数,第四列是操作数的说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 0 LOAD_CONST               0 (0)
2 <0>
4 <0>
6 STORE_NAME 0 (0)
8 <0>
10 POP_TOP
12 RETURN_VALUE
14 ROT_TWO
16 <0>
18 <99> 0
20 <0>
22 <0>
24 <0>
26 <0>
28 <0>
30 <0>
32 <0>
34 <0>
36 NOP
38 <0>
40 LOAD_CONST 1 (1)
42 <0>
44 YIELD_FROM
46 <0>
48 RETURN_VALUE

​ 简单程序的字节码容易分析,但是如果一个大型程序被编译成了 pyc 文件,就难以分析了。

​ 除了CODE_CODE的PyObject,还有其他的PyObject,本例还有:

image-20221202142731305

​ 这些部分的结构大同小异,就不一一分析了。

​ 这里有一份 PyCodeObject 的具体定义,感兴趣的同学可以仔细看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Include/code.h
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) See
Objects/lnotab_notes.txt for details. */
void *co_zombieframe; /* for optimization only (see frameobject.c) */
PyObject *co_weakreflist; /* to support weakrefs to code objects */
} PyCodeObject;

​ 一个 pyc 文件里面可能包含很多的 PyCodeObject,实际上,一个 PyCodeObject 的定义范围是有限的,例如一个函数就定义在一个 PyCodeObject 里面,一个类、闭包等等都分别定义在不同的 PyCodeObject 里。如下图:

image-20221202142935459

0x03 pyc字节码的处理

​ 保护 python 程序难度很高,因为 python 程序的载体 .py 就是源代码文件,虽然有 pyc 这种不能直接看懂的文件,但是由于 uncompyle6 这样的神器存在,解析它也不在话下,目前保护 python 程序的思路一般是对变量名进行混淆,或者操作 pyc 文件混淆字节码,显然后者的效果要更好一些。
​ python 也好 C 语言也罢,万变不离其宗,python 的字节码处理其实和混淆一个 exe 程序类似,简单的包括跳转混淆、控制流混淆,复杂一些的可能涉及 byte-code 加密等等。

​ 我们拿出最简单的一种方法分析,通过强制跳转干扰反编译器的工作。
​ 首先要了解一些字节码的知识,可以用下面的代码获取你当前版本的 python 字节码表:

1
2
3
import opcode  
for op in range(len(opcode.opname)):
print('0x%.2X(%.3d): %s' % (op, op, opcode.opname[op]))

​ 在 pyc 文件中,字节码的格式一般是 opcode + 操作数,如果想要利用强制跳转实现字节码混淆的话,首先要找到强制跳转的字节码,我的机器上这条指令字节码是 0x71,我们会尝试构造这种结构。

image-20221202143239876

​ 这种结构如下:

image-20221202143316523

​ uncompyle 的工作原理和一般的反编译器类似,它会尽力去匹配每一条指令,尝试将所有指令都覆盖到,但是在解析上面的代码时,碰到 load 不存在的常量时就会出错,无法继续反编译。

byte-code加密混淆:把 python 源代码取下来,将内部的 opcode 部分进行重新排序再编译回 python 解释器。

留言

2022-12-02

© 2024 wd-z711

⬆︎TOP