Pickle反序列化与Python原型链污染
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__()函数的返回值可以被序列化的类
我的理解就是pickle序列化的内容(也就是opcode+operand)可执行范围是有限的,而手动构造的opcode与operand可执行范围就远高于pickle序列化生成的opcode与operand。我们以下面这段代码为例:
1 | import pickle |
他的输出结果为:
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.' |
其中第一段就是我们的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,在栈上放一个空元组
\x81:NEWOBJ,表示使用刚才引入的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\x00flag和X\x0e\x00\x00\x00ZLARYY{2L47yY}
u:SETITEMS,遇到这个指令,PVM开始找(标记,把这期间所有的键值塞进之前创建的字典里,此时字典变成了:{'age': 18, 'flag': 'ZLARYY{2L47yY}', 'friends': 'Chesmond and Arache'}
这些内容全部用来构建属性字典
1 | b. |
b:BUILD,表示把刚做好的字典赋值给刚才创建的空对象__dict__属性,此时ZLARYY这个对象有了属性
.:STOP,在反序列化时表示反序列化结束,返回最终的对象
而我们输出的pickle.loads(opcode):<__main__.ZLARYY object at 0x000001C061A19108>
指明了这个对象属于__main__模块的ZLARYY类,最后的十六进制为内存地址
Pickle反序列化漏洞
我们介绍了正常情况下的pickle序列化字符串组成,但我们之前也提到了pickle解析能力大于执行能力,如果我们手动打出恶意的payload,那么就可能会出先RCE等漏洞
就比如下面的代码:
1 | import pickle |
关于这串payload有几个小细节:
这段payload使用的是pickle的第0版协议,在这个协议中,换行符作为分隔符使用,如果不使用多行字符串而强行将payload写成一行会变成:
1
payload = b'cos\nsystem\n(S\'whoami\'\ntR.'
为了方便手动编写及阅读修改,通常采用多行字符串的方式,而且由于我们的pickle序列化字符串必须带上b,所以采用
b'''的方式来书写多行字节串
那么这段paylaod是怎么起作用的:
1 | 第一行:cos |
c:GLOBAL指令,之前提到是作为引入实例化对象,在这里是作为导入模块
os:模块名,这里PVM读到回车就表示模块名写完了
1 | 第二行:system |
system:函数名,这里PVM读到回车表示函数名写完了,此时PVM将os.system这个函数放在了内存栈中
1 | 第三行:(S'whoami' |
(:MARK指令,作为标记
S:STRING指令,读取字符串
'whoami':字符串内容,这里PVM读到回车就表示字符串读完了,把它压入栈中
1 | 第四行:tR. |
t:TUPLE指令,找到上一个MARK指令,并把之间的数据打包为元组:('whoami',)
R:REDUCE指令,选择栈上的第一个对象作为函数,第二个对象作为参数(第二个对象必须是元组),然后调用该函数,也就是执行了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 | b'''cos |
i
在前文表格中提到过:i相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
用它写的paylaod如下:
1 | b'''(S'whoami' |
o
o可以寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
1 | b'''(cos |
接下来我们尝试搭建一个本地环境测试一下,题目为:
1 | import base64 |
根据之前所说,我们构造的pickle反序列化内容应该是:
1 | b'''cos |
这里会对我们传进去的内容进行base64解码,那么我们的参数就应该先进行一次base64编码,最终paylaod为:
1 | /calc?payload=Y29zCnN5c3RlbQooUydjYXQgZmxhZy50eHQgPiBldmlsJwp0Ui4= |
同时我们需要注意的是,直接将我们的payload放在在线编码里面得到的payload是不行的,必须用:
1 | import base64 |
这段脚本进行编码
Python原型链污染
漏洞代码
1 | def merge(target, payload): |
我们挨个分析这串代码的含义:
payload.items()
1 | payload={"key1":"value1","key2":"value2"} |
我们以这串代码为例看看输出结果:
.items()是python字典中的一个内置方法,作用是将字典里的”键”和”值”成对地拿出来,就可以用for循环挨个处理
1 | for key, value in payload.items(): |
所以这串代码的意思就是遍历payload的键值对,如果merge的第一个参数target是字典,那么继续,如果payload中取得的value是字典并且取得的键key存在于target中,那么就将target替换为target[‘key’]继续执行merge函数
如果value不是字典并且取得的key存在于target中,那么就将target[key]的值替换为value
比如:
1 | target1={"theme":"light"} |
1 | elif hasattr(target, key): |
不过如果target中含有取得的key这个属性,那么继续判断,如果取得的value是个字典,那么就会将target的key属性取出来作为新的target进行merge,如果value不是字典那么就将target对象的key属性赋值为value
1 | class ZLARYY: |
由于所有的对象中都存在__class__属性,所以造成了原型链污染
例题
1 | SECRET_KEY="I can't be changed!!!" |
如果要使用__init__,那么merge的对象中必须存在def __init__(self):
1 | class ZLARYY: |
图片中的结果对应上面的两种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 | SECRET_KEY="I can't be changed!!!" |
