pickle
第一次见到这种类型的题,遂记录增长见识。
什么是pickle
pickle是python中的一个标准模块,也被称为“腌制”和“卸腌”,用于将python对象转换为一个字节流,也就是序列化,并且以二进制格式储存,同时也可以把对象从字节流恢复成python对象。值得注意的是,使用pickle序列化的数据只能由pickle反序列化,并不能跨语言。
可序列化的对象
- 基本数据类型:
- 整型(int)
- 浮点型(float)
- 复数(complex)
- 字符串(str)
- 字节串(bytes)
- 字节数组(bytearray)
- 布尔值(bool)
- 容器类型:
- 列表(list)
- 元组(tuple)
- 字典(dict)
- 集合(set)和冻结集合(frozenset)
- None:Python 的
None
值可以被 pickle。 - 函数、类和实例:用户定义的函数、类以及它们的实例可以被 pickle,但有一些限制。例如,函数和类定义所在的代码必须能够在反序列化时被导入。
- 内置类型实例:许多 Python 的内置类型实例,如
datetime
对象,也可以被 pickle。
主要函数
pickle.dump(obj, file, protocol)
:将对象序列化并写入文件。pickle.load(file)
:从文件中读取并反序列化对象。pickle.dumps(obj)
:返回对象序列化后的结果。pickle.loads(bytes)
:从字节流中读取并且反序列化对象。
opcode
opcode
是 “operation code” 的缩写,它指的是一个指令集中的一个操作码或操作码,pickle由于有不同的实现版本,在py3和py2中得到的opcode不相同。但是pickle可以向下兼容(所以用v0就可以在所有版本中执行),工作原理大致如下(白嫖的图)
常用的opcode如下:
opcode | 描述 | 具体写法 | 栈上的变化 | memo上的变化 |
---|---|---|---|---|
c | 获取一个全局对象或import一个模块(注:会调用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标记以及被组合的数据出栈,列表被更新 | 无 |
pickletools
使用pickletools可以方便的将opcode转化为便于肉眼读取的形式
例如
1 | import os |
输出为
1 | b'\x80\x04\x95\x1b\x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x03dir\x94\x85\x94R\x94.' |
pickle的利用
- 任意代码执行或者命令执行
- 变量覆盖
变量引用
有点像php,如果知道目标有什么类,我们通过修改被反序列化的内容来进行引用危险变量。
1 | import pickle |
产生的字节码
1 | b'ccopy_reg\n_reconstructor\n(c__main__\ntest\nc__builtin__\nobject\nNtR(dVpwd\nVhahaha\nsb.' |
当运行
1 | # 假设 secret 模块如下定义: |
结果
1 | {'pwd': 's3cr3t'} |
成功引用。
任意代码执行
漏洞产生(__ reduce __)
先看一个简单的代码
1 | import os |
当执行这段代码时便会输出当前目录
由
1 | b'\x80\x04\x95\x1b\x00\x00\x00\x00\x00\x00\x00\x8c\x02nt\x94\x8c\x06system\x94\x93\x94\x8c\x03dir\x94\x85\x94R\x94.' |
35: R REDUCE
可以看到__ reduce __对应的是R指令,R选择栈上的第一个对象作为函数、第二个作为参数(第二个必须为元组)然后执行。查询得知R指令调用了load_reduce方法,查看源码:
1 | def load_reduce(self): |
按照当前的例子说就是,取当前栈栈顶的元素标记位func,然后以args为参数,执行函数func,再把结果压进了当前栈中,即func对应例子中的system,而*args对应的是dir,导致了RCE。
为什么手动构造opcode
在CTF中,很多时候需要一次执行多个函数或一次进行多个指令,此时就不能光用 __reduce__
来解决问题(reduce一次只能执行一个函数,当exec被禁用时,就不能一次执行多条指令了),这时我们就可以构造opcode来进行命令执行。因为版本0的opcode更方便阅读,所以手写一般用的是版本0.
常用操作符c,R,o,i,b。
c
源码
1 | def load_global(self): |
c
操作符把find_class()
函数返回的一个类对象压入栈,通过__import__()
引入了模块并且通过self.proto
判断pickle版本处理了不同版本的函数名称问题.
举例
1 | import pickle |
输出:
1 | 0: c GLOBAL '__main__ os system' # 导入 os.system |
效果:反序列化后会执行 os.system("calc")
(弹出计算器)。
R
1 | opcode=b'''cos |
cos
system
将os.system压入栈中
(
向栈中压入Mark
S’whoami’
将字符串whoami压入栈中
t寻找栈中的上一个Mark,并组合之间的数组为元组(‘whoami’)
R选择栈上的第一个对象作为函数、第二个作为参数(第二个必须为元组),
然后调用该函数os.system(‘whoami’)
点号结束
o
1 | opcode=b'''(cos |
寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
i
1 | opcode=b'''(S'whoami' |
(S’whoami’Mark压入栈中,字符串压入栈中
ios\nsystem
i指令相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
b
查看源码
1 | def load_build(self): |
简单地说,b
操作符有两种用法
- 向一个实例中插入属性,或覆盖属性
- 以一个实例的
__setstate__
属性为func
,b
的前一个元素当作arg
,执行func(arg)
变量覆盖
示例:
1 | import pickle |
c__main__
:创建一个在__main__
模块中定义的类的实例。s
:表示接下来的操作是保存一个对象的字典(即实例的属性)。(S'name'
:表示一个键,这里是字符串'name'
。S'Funny_M0nk3y'
:表示一个值,这里是字符串'Funny_M0nk3y'
。db.
:结束保存字典的操作,然后是 pickle 协议的结束标记。这时name的值便被修改为Funny_M0nk3y。
绕过
find_class()中出现过滤
有些题中通过在find_class()中加入黑白名单来防止调用危险函数
绕过方法
通过使用builtins模块构造getattr函数,不再经过find_class,就能绕过WAF实现任意函数执行。
builtins模块:builtins
是 Python 的内置模块,包含所有内置函数和类(如 getattr
、str
、dict
等)。攻击者可能通过它间接访问危险函数。
getattr:
getattr
是 builtins
模块中的一个函数,用于从对象中获取指定的属性。
示例:
1 | builtins.getattr(builtins.getattr(builtins.dict,'get')(builtins.golbals(),'builtins'),'eval')(command) |
builtins.dict: 这是Python内置的
dict
类。builtins.getattr(builtins.dict, ‘get’): 这里使用
getattr
函数来获取dict
类的get
方法。get
方法是字典对象的一个方法,用于获取指定键的值,如果键不存在,则返回默认值(如果没有提供默认值,则返回None
)。builtins.getattr(builtins.dict, ‘get’)(builtins.globals(), ‘builtins’): 这一步执行了上一步获取的
get
方法。builtins.globals()
返回当前全局符号表,这是一个字典,包含了当前全局范围内的所有变量。然后,我们使用get
方法从这个全局字典中获取键'builtins'
的值。在全局字典中,键'builtins'
对应的是builtins
模块本身。builtins.getattr(…, ‘eval’): 最后,我们再次使用
getattr
函数,这次是从上一步返回的builtins
模块中获取'eval'
属性。eval
是一个内置函数,它能够执行一个字符串作为Python代码。5.
(command)
: 最后,我们将command
作为参数传递给eval
函数。这意味着command
字符串将被当作Python代码执行。
难点在于如何转化成opcode,这里我们可以借助pker的辅助来进行生成。
写成opcode就是这样的
1 | geteval = b'''cbuiltins |
绕过显式字符串检测
V
操作符可以进行unicode编码
1 | Vsecr\u0065t |
S
操作符可以识别十六进制
1 | S'\x73ecret' |