SGLang 是当前部署 LLM 的主流推理框架之一,以 RadixAttention 和极高的吞吐著称。但过去一年里,它几乎成了"不安全反序列化"的活教材:从 2025 年的 CVE-2025-10164 开始,SGLang 一路被挖出十几个反序列化相关的漏洞。9 月 11 日公开的 CVE-2026-86793 是这串链条的最新一环,而且它打的是一个"修复"——SafeUnpickler。

事情有意思的地方在于:SafeUnpickler 本身就是为了堵住 CVE-2025-10164 才引入的。结果它没堵住,因为设计上用了 denylist(黑名单)而不是 allowlist(白名单),而黑名单漏掉了两个最不该漏的名字:__import__ 和 getattr。

入口:一个"无密钥即放行"的管理端点

攻击起点是 /update_weights_from_tensor,定义在 python/sglang/srt/entrypoints/http_server.py。这个端点的鉴权级别是 AuthLevel.ADMIN_OPTIONAL,它的判定逻辑可以简化成一段决策树:

if endpoint_is_admin_optional:
    if admin_api_key_is_configured:
        require_admin_api_key()
    elif api_key_is_configured:
        require_api_key()
    else:
        allow_request()   # 没配任何 key,直接放行

最后那个分支就是放大器。SGLang 默认绑定 loopback,但绝大多数生产部署是通过 Docker、Kubernetes 或云 GPU 平台暴露出去的,而且操作者经常不配 API key——配 key 是"可选"的。于是这个本应只对管理员开放的"更新模型权重"端点,在默认配置下就变成了任何人都能 POST 的无鉴权入口。

攻击者往 serialized_named_tensors 数组里塞一段 base64 编码的 pickle 字节流,POST 过去,SGLang 会解码后交给 SafeUnpickler 反序列化。

SafeUnpickler 为什么是坏的

SafeUnpickler 位于 python/sglang/srt/utils/common.py,重写了 pickle 的 find_class() 方法。它的防护靠两个机制:

ALLOWED_MODULE_PREFIXES = ("torch.", "numpy.", "builtins.", ...)
DENY_CLASSES = {("builtins", "eval"), ("builtins", "exec"),
                ("builtins", "compile"), ("builtins", "open")}

def find_class(self, module, name):
    if (module, name) in self.DENY_CLASSES:
        raise pickle.UnpicklingError("Denied")
    if module.startswith(self.ALLOWED_MODULE_PREFIXES):
        return super().find_class(module, name)
    raise pickle.UnpicklingError("Not allowed")

问题有两个,都是老毛病:

第一,"builtins." 这个前缀太宽。前缀匹配意味着凡是 builtins. 开头的东西都放行,除非它恰好进了 DENY_CLASSES。而 Python 的 builtins 模块里有几百个名字,denylist 只拦了四个。

第二,denylist 不完整。它拦了 eval、exec、compile、open,却没拦 __import__ 和 getattr。这两个是反射原语——有了它们,攻击者不需要直接命中任何被拦的名字,就能动态拿到任意模块里的任意函数。

绕过链:不用打破规则,规则本身就有洞

整个利用不需要任何"绕过黑名单"的技巧,它只是顺着 allowlist 允许的名字走。用 Python 语义写出来是这样:

import builtins

f_import = builtins.__import__   # find_class 只看到 builtins.__import__,放行
f_getattr = builtins.getattr      # 同理,builtins.getattr 放行

os_mod  = f_import("os")          # REDUCE: __import__("os")
os_sys  = f_getattr(os_mod, "system")  # REDUCE: getattr(os, "system")
os_sys("touch /tmp/poc_confirmed")     # REDUCE: os.system("touch ...")

对应到 pickle 字节码,就是三段 GLOBAL + REDUCE 的组合:

  1. GLOBAL builtins __import__,压入字符串 "os",REDUCE 调用 __import__("os"),栈顶得到 os 模块。
  2. GLOBAL builtins getattr,REDUCE 调用 getattr(os, "system"),栈顶得到 os.system。
  3. 压入命令字符串 "touch /tmp/poc_confirmed",REDUCE 执行 os.system(...)。

关键在最后一步:find_class() 只检查 GLOBAL 阶段要解析的 (module, name) 对。整条链里它只看到 ("builtins", "__import__") 和 ("builtins", "getattr") 这两对,而 ("os", "system") 这个真正危险的名字对,是通过 getattr 动态取出来的,find_class 根本不会直接处理它。

也就是说,攻击者把 os.system 藏在了 getattr 的一次间接引用后面。denylist 想拦的是"危险的名字",但攻击者用两个"安全的名字"拼出了危险的名字。

这不是第一个,也不会是最后一个

CVE-2026-86793 的 CVSS 是 9.8(Critical),CWE-94。但更值得关注的是它处在怎样一条时间线上:

CVE 时间 组件 问题
CVE-2025-10164 2025-09 update_weights_from_tensor 最早的 pickle RCE,SafeUnpickler 因它而生
CVE-2026-3059 2026-03 多模态 ZMQ broker pickle.loads(),tcp://* 绑定,零鉴权,9.8
CVE-2026-3060 2026-03 编码器 disaggregation 同样的 pickle.loads(),9.8
CVE-2026-5760 2026-04 /v1/rerank 未沙箱的 Jinja2 Environment() 渲染,RCE
CVE-2026-7669 2026-05 get_tokenizer trust_remote_code=False 被静默覆盖成 True
CVE-2026-15969~15978 2026-07 六连 SafeUnpickler 绕过 / eval 逃逸 / SSRF / 凭据泄露 / 权重窃取
CVE-2026-86793 2026-09 SafeUnpickler builtins 前缀 + __import__/getattr 绕过

去年 7 月 30 日 CERT/CC 一口气挂了六个 CVE(VU#281278),安全研究者 Apoorv Dayal 在那篇《Breaking SGLang's Trust Boundaries》里说得很透:这六个洞看起来互不相干,本质却是同一个架构问题——推理服务把"推理、管理、模型加载、分布式权重"四个信任域塞进了同一个端口,而鉴权策略是"没配 key 就放行"。

他把根因归结为三条:可选鉴权(default fall-open)、可执行的数据格式(pickle)、不受约束的服务端抓取(SSRF)。CVE-2026-86793 踩的是前两条。

根因:denylist 永远追不上,fall-open 才是放大器

技术上,SafeUnpickler 的问题一句话就能说清:denylist 是"已知坏名单",而攻击面是无限的。你补上 __import__,还有 getattr;补上 getattr,还有 vars、__getattribute__、globals……Python 反射原语之间的跳板几乎无穷无尽。真正安全的做法只有一个:白名单——显式列出允许反序列化的精确类名,其余一律拒绝。

架构上,比 denylist 更危险的是 ADMIN_OPTIONAL 的 fall-open。单个反序列化漏洞还有"需要能碰到端点"这个前提,而 fall-open 把"能碰到"从"需要管理员权限"降级成了"能连上端口"。两者叠加,才把一个需要配 key 才危险的管理操作,变成了默认配置下的公网 RCE。

我的做法

如果团队在跑 SGLang(或 vLLM、TGI 这类同类框架),我会做三件事:

1. 先确认暴露面。 推理服务默认监听 30000 端口,用 ss 看有没有裸奔:

ss -tlnp | grep -E ':30000\b|:8000\b'
# 直接探测无鉴权端点
curl -s http://<host>:30000/server_info | head -30   # CVE-2026-15977 会回 API key
curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST http://<host>:30000/update_weights_from_tensor

2. 配 key,别让 fall-open 生效。 启动时显式传 --api-key 和 --admin-api-key,并且只在受信任内网暴露,管理端点走反向代理加二次鉴权。

3. 反序列化侧,别指望官方 patch(截至发稿还没发)。 如果必须自定义 unpickler,写成白名单:

ALLOWED = {"torch.Tensor", "numpy.ndarray", "numpy.dtype"}

def find_class(self, module, name):
    key = f"{module}.{name}"
    if key not in ALLOWED:
        raise pickle.UnpicklingError(f"Blocked: {key}")
    return super().find_class(module, name)

最治本的是换掉 pickle 本身:加载模型权重用 safetensors(纯数据格式,无执行语义),把"模型文件可以执行代码"这条路从根上切断。SGLang 这一串 CVE,绝大多数都源于"模型加载"和"权重更新"走了 pickle——换成 safetensors,这些洞大半不复存在。

一个更大的提醒是给所有把 LLM 服务直接暴露出去的团队的:推理服务器不是"算力 API",它内部是控制平面。它管着昂贵的 GPU、管着模型权重、管着 API key。把它当一个无状态的 Web 服务来部署,等于把机房的钥匙挂在了门把手上。

⚠️ 网络安全免责声明

本文内容仅供技术研究与安全学习之用。文中描述的漏洞分析、攻击链拆解和代码示例均基于公开披露的安全研究,旨在帮助开发者理解攻击原理、提升安全意识和防御能力。

请勿将本文所述技术用于任何未经授权的安全测试、渗透攻击或非法用途。任何因滥用本文信息而造成的法律后果,由使用者自行承担,与本文作者无关。

如发现系统安全漏洞,请通过合法渠道向相关厂商或平台报告,共同维护网络安全生态。