偷偷摸摸来开一个新坑~
# 前置铺垫
这段时间看到一个 pickle 反序列化实现变量覆盖的题目,正好之前没怎么接触过,开一个坑来浅浅了解一下
先来看一个简单的例子
import pickle | |
class Person: | |
def __init__(self): | |
self.name = "ctf" | |
self.age = 20 | |
p = Person() | |
opcode = pickle.dumps(p) | |
print(opcode) |
其序列化结果是 b'\x80\x04\x954\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x06Person\x94\x93\x94)\x81\x94}\x94(\x8c\x04name\x94\x8c\x03ctf\x94\x8c\x03age\x94K\x14ub.'
返回的是字节码,内置的 pickletools 模块可以反汇编字节码帮助解读
0: \x80 PROTO 4 Protocol version indicator. | |
2: \x95 FRAME 52 Indicate the beginning of a new frame. | |
11: \x8c SHORT_BINUNICODE '__main__' Push a Python Unicode string object. | |
21: \x94 MEMOIZE (as 0) Store the stack top into the memo. The stack is not popped. | |
22: \x8c SHORT_BINUNICODE 'Person' Push a Python Unicode string object. | |
30: \x94 MEMOIZE (as 1) Store the stack top into the memo. The stack is not popped. | |
31: \x93 STACK_GLOBAL Push a global object (module.attr) on the stack. | |
32: \x94 MEMOIZE (as 2) Store the stack top into the memo. The stack is not popped. | |
33: ) EMPTY_TUPLE Push an empty tuple. | |
34: \x81 NEWOBJ Build an object instance. | |
35: \x94 MEMOIZE (as 3) Store the stack top into the memo. The stack is not popped. | |
36: } EMPTY_DICT Push an empty dict. | |
37: \x94 MEMOIZE (as 4) Store the stack top into the memo. The stack is not popped. | |
38: ( MARK Push markobject onto the stack. | |
39: \x8c SHORT_BINUNICODE 'name' Push a Python Unicode string object. | |
45: \x94 MEMOIZE (as 5) Store the stack top into the memo. The stack is not popped. | |
46: \x8c SHORT_BINUNICODE 'ctf' Push a Python Unicode string object. | |
51: \x94 MEMOIZE (as 6) Store the stack top into the memo. The stack is not popped. | |
52: \x8c SHORT_BINUNICODE 'age' Push a Python Unicode string object. | |
57: \x94 MEMOIZE (as 7) Store the stack top into the memo. The stack is not popped. | |
58: K BININT1 20 Push a one-byte unsigned integer. | |
60: u SETITEMS (MARK at 38) Add an arbitrary number of key+value pairs to an existing dict. | |
61: b BUILD Finish building an object, via __setstate__ or dict update. | |
62: . STOP Stop the unpickling machine. |
这给你展现得明明白白
后续的反序列化就是通过 pickle.loads () 调用 pickle.py 中的 _Unpickler 对象实现 (当然 loads 调用只是其中一种用法)
这里简单描述一下其中的 load 函数

其实就是依次按表读取并操作,顺着前面序列化返回的字节码,按顺序理解一遍就大概清楚了,具体跟着代码调试就不展开了
# 反序列化漏洞
其本质就是想办法修改字节码从而在反序列化时完成自己想做的操作
# reduce
import pickle | |
import pickletools | |
import os | |
class Person: | |
def __init__(self): | |
self.name = "ctf" | |
self.age = 20 | |
def __reduce__(self): | |
command = r'ls' | |
return (os.system, (command,)) | |
p = Person() | |
opcode = pickle.dumps(p) | |
pickletools.dis(opcode, annotate=1) | |
P = pickle.loads(opcode) | |
print(P) |
__reduce__ 是 python 的协议方法,可以用于自定义对象的序列化行为。当返回字符串时直接在全局命名空间中查找该名称对应的对象;当返回元组时调用 callable 并传入 args 来重建对象
上面这个例子就是调用 os.system 并传入 command
之前的 reduce 虽好,但只能只能执行一个函数,当遇到需要执行多函数解决问题时就需要依靠手动编写 opcode 来解决
这里还提一嘴 pickle 不同写协议版本的 opcode 写法不一样,但是向下兼容,即版本为 0 的 opcode 可以在任意版本 picke 中被反序列化且比较容易看懂
关于其中具体操作码部分就不过多阐述,在 pickle.py 中你可以找到详细释义(其实是简陋的英文版注释)
先通过几个例子练练手熟悉一下
# 实例化对象
class Person: | |
def __init__(self,name,age): | |
self.name = name | |
self.age = age |
尝试手动编写一下 opcode 实例化一下 Person 对象
首先 c__main__\nPerson\n ,表示 import __main__ 模块获取 Person 类
然后 ( 表示开始参数元组
其次 S'ctf'\nI20\n 表示设置参数
再是 tR. 表示结束元组重建对象并结束
结合来看就是 c__main__\nPerson\n(S'ctf'\nI20\ntR.
验证一下:
# 构造函数
构造函数进行命令执行存在经典三操作码:R、i、o(Rio 说是)
其中对于 R 的描述是, apply callable to argtuple, both on stack ,即选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数
其实就和上面实例化对象差不多: cos\nsystem\n(S'ls'\ntR.
然后我们先来看 o, build & push class instance 扩写一下就是,寻找栈中的上一个 MARK,以之间的第一个数据(必须为函数)为 callable,第二个到第 n 个数据为参数,执行该函数(或实例化一个对象)
MARK 就是 (, 写一下例子就看得懂了
首先是 ( 起手,后面就是一样的操作:
(cos | |
system | |
S'ls' | |
o. |
合起来就是 (cos\nsystem\nS'ls'\no.
最后一个 i,其注释和 o 一模一样,但是它其实相当于 c 和 o 的组合,先获取一个全局函数,然后寻找栈中的上一个 MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
听起来比较绕,实际上就是在 o 的基础上稍微换个顺序,先获取全局函数就 ios\nsystem ,将夹在全局函数与上个 MARK 之间的东西作为参数来执行函数
(S'ls' | |
ios | |
system | |
. |
# 变量覆盖
这个操作使用操作码 d 与 b,d 操作码具体就是寻找栈中的上一个 MARK,并组合之间的数据为字典(数据必须有偶数个,即呈 key-value 对)
b 就是使用栈中的第一个元素(储存多个属性名:属性值的字典)对第二个元素(对象实例)进行属性设置
比如我们设置一个 key 在 secret.py,现在想将其改为别的值
c__main__ | |
secret | |
(S'key' | |
S'abc' | |
db. |

# 绕过 WAF
前面提到 pickle 会 unpickling 一些带有恶意代码的字节流从而造成危害,除了主观上建议不要反序列化不受信任的或来源不确定的数据外,还有一个叫做 find_class() 的函数,通过重写的方式添加白名单来限制全局变量
简单看一下函数原本的样子
那段动态导入模块的语句就是漏洞来源,这里还需要清楚一点,那就是什么时候会调用这个函数,还是从源代码寻找答案
这是 INST 指令(操作码 i)python2 用于实例创建的 opcode,这里调用了一次
这是 GLOBAL 指令(操作码 c)python3 用于加载某个模块里的全局类或函数
这是 STACK_GLOBAL 指令(操作码 b'\x93'), 协议 5(Python3.8+),也是加载全局对象,但模块名和对象名从栈顶读取
从这里可以看出这都是调用 find_class 的函数,用于处理特定 opcode 而且其中的 find_class 只会被调用一次,只要你能绕过这里,后面就畅通无阻。
官方文档里以 __builtins__ 作为白名单示例
当然这段不仅限制为 builtins 模块而且还只能使用其中的安全类,没法绕,所以我们讨论的范围仅是 builtins 模块或者其他不是很严格的限制
其实这一部分也是老生常谈,对经常写沙箱逃逸的师傅估计是洒洒水啦
为了水字数也说一说 ()
比如在 builtins 模块下限制了常见的 eval、exec 这种危险代码类,还有 __import__ 模块导入、globals () 获取所有模块内容结合 getattr 这种通过属性访问获取命令执行函数的形式等
别的比如限制白名单为 __main__ ,有引用全局变量啥的,也可以根据题目进行变量覆盖
当然思路是这样的,实操起来肯定还会涉及很多编写 opcode 的情况,那就是另一回事了,前面基本都提过常用的一些 opcode 写法
# 总结
面对 pickle 反序列化问题,其实总共就分为两部分,一个是有什么限制条件,怎么绕过,另一个就是如何编写对应的 opcode 来实现你的绕过思路
参考文章
https://xz.aliyun.com/news/7032#toc-0
https://goodapple.top/archives/1069
https://docs.python.org/zh-cn/3/library/pickle.html#restricting-globals
