请求失败了,但代码早已执行。
请求失败了,但代码早已执行。这正是 LMCache CVE-2026-105192 的顺序问题:一个不安全的反序列化器在参数解码阶段就已执行,业务处理函数还没来得及拒绝它们。等到返回错误响应时,已经无法证明这个请求是无害的。
该安全公告覆盖 LMCache 0.3.9 至 0.5.5、受影响的 0.5.6 预发布版本,以及修复之前的开发构建。0.5.6 版本包含了修复。本文是对源代码和补丁的审查,并非复现;在讨论后文的负向测试时,这一区别尤为重要。受影响的是可被访问到的多进程传输组件,而不是所有使用 vLLM 的应用。[1][2]
MessagePack 解码是如何走到 pickle 的?
该传输层接受 MessagePack,但它的自定义扩展钩子会对某些字节进行第二次解释。扩展码 1 会进入 DeviceIPCWrapper.Deserialize,而其存在漏洞的实现调用了 pickle.loads。在 REGISTER_KV_CACHE 路径中,这一切发生在服务器仍在构造将要传给处理函数的参数时。事后再检查这些参数,无法撤销已经发生的副作用。[1][3]
在审计中,我会这样追踪这条边界:从网络解码器开始,列出所有钩子和自定义转换器,逐一跟踪,直到字节变成普通数据或可执行的对象重建为止。架构图上写的格式名称并不足以说明问题。一个面向数据的外层格式,完全可能包含一个扩展,把外层看似已经避开的危险行为全部带回来。
Affected decode path (schematic)
Peer-controlled frame
-> MessagePack argument decode
-> extension code 1
-> DeviceIPCWrapper.Deserialize
-> pickle.loads
-> only then: handler / later validation
A later error cannot undo an earlier side effect.开发者需要还原出正确的子类
存在漏洞的代码说明了它的工程目标:在跨进程传输中保留设备包装器的具体类型,让接收方分派到正确的张量转换实现。CPU 包装器和加速器包装器承担着不同的工作。序列化整个对象很方便地解决了分派问题,但也让对端能够把字节交给 Python 对象重建过程。安全错误出在为一个合理需求所选择的数据表示方式上。[3]
因此,与其笼统地要求“不要用 pickle”,不如问一个更有用的审计问题:哪些信息必须跨越边界,哪些实现选择必须留在本地?接收方可能需要设备类型、形状或存储描述符,但它不需要接受一份构造任意 Python 对象的“配方”。把这一区别写下来,就为补丁确立了一个即使更换传输方式也依然成立的不变量。
补丁把实现的选择权交还给本地代码
在 0.5.6 中,Deserialize 先解码一个带类型的 WireIPCData 描述符,再根据其类型从 WRAPPER_TYPES 中选择条目。模块和类的选择来自这张本地映射表。修复后的路径中出现 importlib,本身并不构成新的注入问题:关键在于输入是否控制了被导入的名称,还是只能从明确支持的描述符中进行选择。[4]
补丁保留了原有的序列化接口,但会拒绝旧的 pickle 数据。我的验收标准是:畸形或不受支持的传输数据必须在任何任意对象重建之前失败,并且不存在悄悄退回 pickle 的兼容分支。在审计中,我会测试未知标签、意外字段和旧版描述符,同时也测试受支持的合法输入。安全补丁必须在消除危险解释的同时,保留原本应有的功能。
# Selected source lines; surrounding checks omitted [3][4]
# Vulnerable:
return pickle.loads(data)
# Fixed:
descriptor = msgspec.msgpack.decode(data, type=WireIPCData)
module, name = WRAPPER_TYPES[type(descriptor)]
# The fixed path selects implementation from a local mapping.沿着共享解码器,追踪到第一个套接字之外
值得审查的调用方不只有 ZeroMQ。修复 PR 指出,有一个 gRPC 编解码器通过名为 pickled_payload 的字段使用同一个反序列化器。出于兼容性考虑,这个字段名被保留了下来,尽管其内容已经变成新的描述符。用 grep 搜索旧字段名可能会夸大风险;只搜索第一个传输层,又可能漏掉共享的边界。请追踪真实的数据流。[5]
这次修复的范围也比“移除 LMCache 中所有 pickle 用法”要窄。维护者指出了此次修复之外的其他由引擎驱动的路径,并说明它们默认处于禁用状态,在该配置下无法通过网络访问。这是他们对范围的声明,而不是本文验证过的可达性结论。补丁并没有建立 RPC 认证,也没有建立使用被引用共享内存资源的授权。语法、身份和资源归属是三项不同的检查。[5][6]
合法的描述符并不能证明资源归属
新的 Schema 不只是换了一个序列化库。TensorMetadata 约束了数值字段,并检查 shape 与 stride 的维数是否一致;CPU 共享内存描述符则检查所描述的张量视图是否落在声明的大小之内。这些检查在包装器使用描述符之前,就限定了描述符的含义。它们是审计人员可以转化为边界用例的具体属性,而不是把“有类型”三个字当作安全保证。[7]
还有一个独立的问题:谁有权命名或使用底层资源?一份内部自洽的描述,本身并不能回答这个问题。该 PR 明确地把描述符校验与共享内存归属授权区分开来。我会把资源归属检查及其所在位置,与解码检查分开记录,并在系统的信任模型需要时,要求两者都提供证据。这并不是说已修复版本中存在新漏洞,而是说明了这个补丁所能确立的边界。同样的原则适用于任何解析器修复:列出它现在强制保证的不变量,再找出它从未承诺过的、独立的安全决策。解析器测试通过,并不能“连带”证明这些决策同样成立。[5]
标记不存在,必须先证明处理已经完成
测试可能在存在漏洞的服务器处理完输入之前,就已经报告了失败。PR 作者明确处理了这个异步时序问题:在检查标记之前,先在同一连接上发送一个后续请求,以确认处理已经完成。文中所报告的修复前后对比结果属于该作者,并非我在实验环境中得到的结果。普遍的经验是:要证明检查发生在相关工作完成之后。[5]
我会要求本地回归测试环境给出四项观察结果:请求到达了预期的解码器、处理已经完成、被禁止的副作用没有出现,以及合法请求依然可以正常工作。超时不能替代拒绝。异常信息也不能替代对副作用的观察:存在漏洞的路径完全可能在执行之后才抛出异常。每一条负向断言,都要与它真正能证明的代码路径和完成条件对应起来。
Proposed regression evidence; not results from a run here
Affected build: path reached + completed + benign marker observed
Fixed build: path reached + completed + marker absent
Valid input: supported round trip still works
No completion: inconclusive, even when the marker is absent先给参与方打补丁,再检查谁能访问它们
部署审查从监听端开始。文档中的默认配置绑定在 localhost;通过 --host 将其暴露出来的多进程部署则有着不同的边界,维护者也特别指出了他们之前的 Kubernetes 示例。localhost 降低了网络暴露面,但并不会对本地对端进行身份认证。在把严重性评分当作对您部署的评估之前,先确认运行模式、已加载的版本、绑定地址以及可以访问它的客户端。[1][6]
由于描述符格式已经改变,请同时升级相互通信的 worker 和服务器;混合版本的集群并不是预期的兼容方案。0.5.6 版本记录了这次修复,维护者另外说明了其他安全工作。升级之后,请继续限制监听端的访问。您能站得住脚的结论是:某一条具体的对象重建路径已经改变,而不是推理服务现在可以安全地面对任意客户端。[2][5]
让代码审计来证明这条边界
一个有价值的发现应当写明入口、自定义钩子、危险操作、其前提条件,以及那些执行得太晚的检查。对它的验证应当明确已部署的版本,并覆盖共享解码器的每一个调用方。这比一条“项目中包含 pickle”的搜索结果要有力得多,因为它告诉团队要改什么,以及如何确认改动已经进入生产环境。
这正是我在安全代码审计中会追踪的那类边界:不可信字节获得“行为”的那一刻,以及让这种行为显得必要的那个假设。如果您的服务通过缓存、队列或 IPC 辅助工具传递对象,就从那里开始。真正棘手的地方,往往藏在大家都以为已经审查过的那个端点之下好几层调用的位置。
参考来源
需要在您自己的产品中检查这个问题吗?
安全代码审计想学会自己发现这类漏洞吗?
安全代码审计课程(一对一)