Python逆向

pyc文件

创建及使用

pyc 文件是 python 在编译 *.py 文件过程中出现的主要中间过程文件,是一种二进制文件,是一种bytecode。pyc 文件是可以由 python 虚拟机直接执行的程序。因此分析 pyc 文件的文件结构对于实现 pyc 反编译就显得十分重要。另外,pyc** 的内容,是跟 python 的版本相关的,不同版本编译后的pyc 文件是不同的,3.8 编译的 pyc 文件,3.7 版本的 python 是无法执行的。

先写个 test.py:

1
2
3
a = 123
def add(v1, v2):
return v1 + v2

我们可以通过 py_compile 模块来生成 pyc 文件:

1
2
from py_compile import *
compile("test.py")

或者直接通过命令行 python -m test.py 生成 pyc。

两种操作都会在当前目录下新建一个 pycache 目录,其中存放着 test.cpython-版本号.pyc 。

如果有一个现成的 pyc 文件,要如何导入它呢?

1
2
3
4
5
6
from importlib.machinery import SourcelessFileLoader
test = SourcelessFileLoader(
"test", "__pycache__/test.cpython-38.pyc"
).load_module()
print(test.a)
print(test.add(1, 2))

文件结构

pyc 文件在创建的时候都会往里面写入如下内容:

1. magic number

这是 Python 定义的一个整数值,不同版本的 Python 会定义不同的 magic number,这个值是为了保证 Python 能够加载正确的 pyc。

比如 Python3.7 不会加载 3.6 版本的 pyc,因为 Python 在加载 pyc 文件的时候会首先检测该 pyc 的magic number。如果和自身的 magic number 不一致,则拒绝加载。

2. 创建时间戳

这个很好理解,在加载 pyc 之前会先比较源代码的最后修改时间和 pyc 文件的写入时间。如果 pyc 文件的写入时间比源代码的修改时间要早,说明在生成 pyc 之后,源代码被修改了,那么会重新编译并写入pyc,而反之则会直接加载已存在的 pyc。

3. py 文件的大小

py 文件的大小也会被记录在 pyc 文件中。

4. PyCodeObject 对象

编译之后的 PyCodeObject 对象,并且是序列化之后再存储。因此 pyc 文件的结构如下:

image-20240204124547436

以上是 Python 3.7+ 的 pyc 文件结构,如果版本低于 3.7,那么开头没有 4 个 \x00。我们实际验

证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import struct
from datetime import datetime
from importlib.util import MAGIC_NUMBER
with open("__pycache__/test.cpython-38.pyc", "rb") as f:
data = f.read()
# 0 ~ 4 字节是 MAGIC NUMBER
print(data[: 4]) # b'U\r\r\n'
print(MAGIC_NUMBER) # b'U\r\r\n'
# 4 ~ 8 字节是 4 个 \x00
print(data[4: 8]) # b'\x00\x00\x00\x00'
# 8 ~ 12 字节是 pyc 的写入时间(小端存储),一个时间戳
ts = struct.unpack("<I", data[8: 12])[0]
print(ts) # 1685686081
print(datetime.fromtimestamp(ts)) # 2023-06-02 14:08:01
# 12 ~ 16 字节是 py 文件的大小
print(struct.unpack("<I", data[12: 16])[0]) # 47

结果和我们分析的一样,因此对于任何一个 pyc 文件来说,前 16 字节是固定的(如果 Python 低于3.7,那么前 12 个字节是固定的)。

16 个字节往后就是 PyCodeObject 对象,并且是序列化之后的,因为该对象显然无法直接存在文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import marshal
with open("__pycache__/test.cpython-38.pyc", "rb") as f:
data = f.read()
# 通过 marshal.loads 反序列化
code = marshal.loads(data[16:]) # 此时就拿到了 py 文件编译之后的 PyCodeObject
print(code)
"""
<code object <module> at 0x..., file "test.py", line 1>
"""
# 查看字节码
print(code.co_code)
# 查看常量池
print(code.co_consts)
# 符号表
print(code.co_names)

既然我们可以根据 pyc 文件反推出 PyCodeObject,那么能否手动构建 PyCodeObject 然后生成 pyc呢?来试一下。例如我们想实现如下的 py 文件:

1
2
3
a = 1
b = 2
c = 3

上述代码编译之后的结果,就是我们要构建的 PyCodeObject。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import time
import struct
import marshal
from opcode import opmap
from types import CodeType
from importlib.util import MAGIC_NUMBER
HEADER = MAGIC_NUMBER + b"\x00" * 4
# 时间随便写
HEADER += struct.pack("<I", int(time.time()))
# 大小随便写
HEADER += struct.pack("<I", 47)
# 构建 PyCodeObject
code = CodeType(
0, # co_argcount
0, # co_posonlyargcount
0, # co_kwonlyargcount
3, # co_nlocals
1, # co_stacksize
0, # co_flags
bytes([
# a = 1 分为两步
# 第一步:先通过 LOAD_CONST 将常量加载进来
# 因此指令是 LOAD_CONST,然后参数是 0
# 表示加载常量池 co_consts 中索引为 0 的常量
opmap["LOAD_CONST"], 0,
# 第二步:通过 STORE_NAME 将常量和符号绑定起来
# 参数是 0,表示和符号表中索引为 0 的符号进行绑定
opmap["STORE_NAME"], 0,
# b = 2
opmap["LOAD_CONST"], 1,
opmap["STORE_NAME"], 1,
# c = 3
opmap["LOAD_CONST"], 2,
opmap["STORE_NAME"], 2,
# 结尾要 LOAD 一个 None,然后返回
opmap["LOAD_CONST"], 3,
opmap["RETURN_VALUE"]
]), # co_code
(1, 2, 3, None), # co_consts
("a", "b", "c"), # co_names
(), # co_varnames
"out.py", # co_filename
"<module>", # co_name
1, # co_firstlineno
b"", # co_lnotab
(), # freevars
() # cellvars
)
# pyc 文件内容
pyc_content = HEADER + marshal.dumps(code)
# 生成 pyc 文件
with open("out.pyc", "wb") as f :
f.write(pyc_content)
# 然后加载生成的 pyc 文件
from importlib.machinery import SourcelessFileLoader
mod = SourcelessFileLoader(
"out", "out.pyc"
).load_module()
print(mod) # <module 'out' from 'out.pyc'>
print(mod.a) # 1
print(mod.b) # 2
print(mod.c) # 3

逆向工具

我们可以通过如下方式来反编译 pyc 文件,拿到源码:

在线工具

pycdc:编译演示https://github.com/zrax/pycdc

uncompyle6:安装方式 pip install uncompyle6

pyc 字节码

反汇编

字节码拿过来反汇编就得到了针对 Python 虚拟机的汇编代码,类似于 x86 汇编,但是比 x86 的好读多了。通过 dis.dis() 就可以将字节码转为可读的伪代码。

1
2
3
4
5
6
import dis
import marshal
with open("out.pyc", 'rb') as f:
raw = f.read()
code = marshal.loads(raw[16:])
dis.dis(code)

输出如下:

1
2
3
4
5
6
7
8
1 0 LOAD_CONST 0 (1)
2 STORE_NAME 0 (a)
4 LOAD_CONST 1 (2)
6 STORE_NAME 1 (b)
8 LOAD_CONST 2 (3)
10 STORE_NAME 2 (c)
12 LOAD_CONST 3 (None)
14 RETURN_VALUE

结构如下:

源码行号 | 指令在函数中的偏移 | 指令符号 | 指令参数 | 实际参数值

常量

LOAD_CONST 用于加载常量,比如数值、字符串等等,一般用于传给函数的参数,例如:

1
2
3
4
55 12 LOAD_GLOBAL 1 (test)
15 LOAD_FAST 0 (2)
18 LOAD_CONST 1 ('output')
21 CALL_FUNCTION 2

转为 python 代码就是:

test(2, “output”)

变量

全局变量

LOAD_GLOBAL 用来加载全局变量,包括指定函数名,类名,模块名等全局符号。

STORE_GLOBAL 用来给全局变量赋值。

例如

1
2
3
4
5
5 0 LOAD_CONST 1 (101)
2 STORE_GLOBAL 0 (num)
6 4 LOAD_GLOBAL 1 (print)
6 LOAD_GLOBAL 0 (num)
8 CALL_FUNCTION 1

对应的 python 代

1
2
3
4
5
num = 0
def test():
global num
num = 101
print(num)

局部变量

LOAD_FAST 一般加载局部变量的值,也就是读取值,用于计算或者函数调用传参等。

STORE_FAST 一般用于保存值到局部变

1
2
3
4
5
6
7
8
2 0 LOAD_CONST 1 (0)
2 STORE_FAST 0 (n)
3 4 LOAD_CONST 1 (0)
6 STORE_FAST 1 (p)
4 8 LOAD_FAST 0 (n)
10 LOAD_FAST 1 (p)
12 BINARY_ADD
14 STORE_FAST 0 (n)

对应的 python 代码

1
2
3
4
def test():
n = 0
p = 0
n = n + p

函数的参数也算局部变量,如何区分出是函数参数还是其他局部变量呢?

参数没有初始化语句,也就是从函数开始到 LOAD_FAST 该变量的位置,如果没有看到 STORE_FAST ,那么该变量就是函数参数。

而局部变量在使用之前肯定会使用 STORE_FAST 进行初始化。

例如:

1
2
3
4
5
6
7
Disassembly of <code object test at 0x1A9F3E62240, file "test.py", line 1>:
2 0 LOAD_CONST 1 (0)
2 STORE_FAST 1 (local)
3 4 LOAD_GLOBAL 0 (print)
6 LOAD_FAST 1 (local)
8 LOAD_FAST 0 (arg1)
10 CALL_FUNCTION 2

对应的 python 代码:

1
2
3
def test(arg1):
local = 0
print(local, arg1)

常用数据类型

列表

BUILD_LIST 用于创建一个 list。

例如

1 0 LOAD_CONST 0 (1)

2 LOAD_CONST 1 (2)

4 BUILD_LIST 2

6 STORE_NAME 0 (k)

对应 k = [1, 2] 。

通过列表推导式(语法糖)构建列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1 0 LOAD_CONST 0 (<code object <listcomp>>)
2 LOAD_CONST 1 ('<listcomp>')
4 MAKE_FUNCTION 0
6 LOAD_NAME 0 (range)
8 LOAD_CONST 2 (32)
10 CALL_FUNCTION 1
12 GET_ITER
14 CALL_FUNCTION 1
16 STORE_NAME 1 (a)
18 LOAD_CONST 3 (None)
20 RETURN_VALUE
Disassembly of <code object <listcomp>>:
1 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 12 (to 18)
6 STORE_FAST 1 (i)
8 LOAD_FAST 1 (i)
10 LOAD_CONST 0 (2)
12 BINARY_ADD
14 LIST_APPEND 2
16 JUMP_ABSOLUTE 4
>> 18 RETURN_VALUE

对应语句 a = [i+2 for i in range(32)]

字典

BUILD_MAP 用于创建一个空的 dict。

STORE_MAP 用于初始化 dict 的内容。

1
2
3
4
5
6
7
8
1 	0 LOAD_CONST 0 ('a')
2 LOAD_CONST 1 (1)
4 BUILD_MAP 1
6 STORE_NAME 0 (k)
2 8 LOAD_CONST 2 (2)
10 LOAD_NAME 0 (k)
12 LOAD_CONST 0 ('a')
14 STORE_SUBSCR

分别对应 k = {‘a’: 1} 和 k[‘a’] = 2 。

切片

BUILD_SLICE 用于创建切片。对于 list、tuple、字符串都可以使用切片的方式进行访问。

但是要注意 BUILD_SLICE 用于 [x:y:z] 这种类型的切片,结合 BINARY_SUBSCR 读取切片的值,结合

STORE_SUBSCR 用于修改切片的值。

例如

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
26
27
28
29
30
31
32
33
1 0 LOAD_CONST 0 (1)
2 LOAD_CONST 1 (2)
4 LOAD_CONST 2 (3)
6 BUILD_LIST 3
8 STORE_NAME 0 (k)
2 10 LOAD_CONST 3 (10)
12 BUILD_LIST 1
14 LOAD_NAME 0 (k)
16 LOAD_CONST 4 (0)
18 LOAD_CONST 0 (1)
20 LOAD_CONST 0 (1)
22 BUILD_SLICE 3
24 STORE_SUBSCR
3 26 LOAD_CONST 5 (11)
28 BUILD_LIST 1
30 LOAD_NAME 0 (k)
32 LOAD_CONST 0 (1)
34 LOAD_CONST 1 (2)
36 BUILD_SLICE 2
38 STORE_SUBSCR
4 40 LOAD_NAME 0 (k)
42 LOAD_CONST 0 (1)
44 LOAD_CONST 1 (2)
46 BUILD_SLICE 2
48 BINARY_SUBSCR
50 STORE_NAME 1 (a)
5 52 LOAD_NAME 0 (k)
54 LOAD_CONST 4 (0)
56 LOAD_CONST 0 (1)
58 LOAD_CONST 0 (1)
60 BUILD_SLICE 3
62 BINARY_SUBSCR
64 STORE_NAME 2 (b)

对应 python 代码

1
2
3
4
5
k = [1, 2, 3]
k[0:1:1] = [10]
k[1:2] = [11]
a = k[1:2]
b = k[0:1:1]

条件判断

以下指令一般用于分支判断跳转:

POP_JUMP_IF_FALSE :表示条件结果为 FALSE 就跳转到目标偏移指令

JUMP_FORWARD :直接跳转到目标偏移指令

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1 0 LOAD_CONST 0 (0)
2 STORE_NAME 0 (i)
2 4 LOAD_NAME 0 (i)
6 LOAD_CONST 1 (5)
8 COMPARE_OP 0 (<)
10 POP_JUMP_IF_FALSE 22
3 12 LOAD_NAME 1 (print)
14 LOAD_CONST 2 ('i < 5')
16 CALL_FUNCTION 1
18 POP_TOP
20 JUMP_FORWARD 26 (to 48)
4 >> 22 LOAD_NAME 0 (i)
24 LOAD_CONST 1 (5)
26 COMPARE_OP 4 (>)
28 POP_JUMP_IF_FALSE 40
5 30 LOAD_NAME 1 (print)
32 LOAD_CONST 3 ('i > 5')
34 CALL_FUNCTION 1
36 POP_TOP
38 JUMP_FORWARD 8 (to 48)
7 >> 40 LOAD_NAME 1 (print)
42 LOAD_CONST 4 ('i = 5')
44 CALL_FUNCTION 1
46 POP_TOP

对应 python 代码:

1
2
3
4
5
6
7
i = 0
if i < 5:
print("i < 5")
elif i > 5:
print("i > 5")
else:
print("i = 5")

循环

while 循环

1
2
3
4
5
6
7
8
9
10
11
1 0 LOAD_CONST 0 (0)
2 STORE_NAME 0 (i)
2 >> 4 LOAD_NAME 0 (i)
6 LOAD_CONST 1 (10)
8 COMPARE_OP 0 (<)
10 POP_JUMP_IF_FALSE 22
3 12 LOAD_NAME 0 (i)
14 LOAD_CONST 2 (1)
16 INPLACE_ADD
18 STORE_NAME 0 (i)
20 JUMP_ABSOLUTE 4

对应 python 代码:

i = 0

while i < 10:

i += 1

for…in 结构

image-20240204125219815

对应 python 代码:

for i in range(10):

print(i ^ 3)

函数

函数范围

前面介绍第二列表示指令在函数中的偏移地址,所以看到 0 就是函数开始,下一个 0 前一条指令就是函

数结束位置,当然也可以通过 RETURN_VALUE 来确定函数结尾。

函数调用

函数调用类似于 push + call 的汇编结构,压栈参数从左到右依次压入(当然不是 push ,而是读取

指令 LOAD_xxxx 来指定参数)。

先指定要调用的函数( LOAD_NAME 或 LOAD_GLOBAL ),然后压参数,最后通过 CALL_FUNCTION 调

用。

CALL_FUNCTION 后面的值表示有几个参数。

支持嵌套调用

image-20240204125253249

对应的 python 代码为:

n = 100

root = int(math.sqrt(n))

pyc 加花混淆

由上面的版块我们知道 pyc 是可以反编译的,而且目前也有现成的工具。但这些工具它会将每一个指令

都解析出来,所以字节码混淆的方式就是往里面插入一些恶意指令(比如加载超出范围的数据),让反

编译工具在解析的时候报错,从而失去作用。

但插入的恶意指令还不能影响解释器执行,因此还要插入一些跳转指令,从而让解释器跳过恶意指令。

image-20240204125318308

image-20240204194442966

image-20240204194452686

pyinstaller 应用

普通打包

image-20240204200105807

exe逆向

image-20240204200135481

elf逆向

image-20240204200150021

使用 key 参数打包

image-20240204200214641

image-20240204200221556

version < 4.0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# For pyinstaller < 4.0
from Crypto.Cipher import AES
import zlib
CRYPT_BLOCK_SIZE = 16
# key obtained from pyimod00_crypto_key
key = 'this_is_a_secret'
inf = open('test.pyc.encrypted', 'rb') # encrypted file input
outf = open('test.pyc', 'wb') # output file
# Initialization vector
iv = inf.read(CRYPT_BLOCK_SIZE)
cipher = AES.new(key, AES.MODE_CFB, iv)
# Decrypt and decompress
plaintext = zlib.decompress(cipher.decrypt(inf.read()))
# Write pyc header
# The header below is for Python 2.7
outf.write('\x03\xf3\x0d\x0a\0\0\0\0')
# Write decrypted data
outf.write(plaintext)
inf.close()
outf.close()

version >= 4.0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# For pyinstaller >= 4.0
import tinyaes
import zlib
CRYPT_BLOCK_SIZE = 16
# key obtained from pyimod00_crypto_key
key = bytes('this_is_a_secret', 'utf-8')
inf = open('test.pyc.encrypted', 'rb') # encrypted file input
outf = open('test.pyc', 'wb') # output file
# Initialization vector
iv = inf.read(CRYPT_BLOCK_SIZE)
cipher = tinyaes.AES(key, iv)
# Decrypt and decompress
plaintext = zlib.decompress(cipher.CTR_xcrypt_buffer(inf.read()))
# Write pyc header
# The header below is for Python 3.8
outf.write(b'\x55\x0d\x0d\x0a\0\0\0\0\0\0\0\0\0\0\0\0')
# Write decrypted data
outf.write(plaintext)
inf.close()
outf.close()

反编译 pyimod02_archive.pyc :

pyinstaller < 4.0 => pycrypto & CFB

pyinstaller >= 4.0 => tinyaes & CTR