从一个辅助 API 看资源边界:CVE-2026-71486(vLLM)
vLLM 在 `0.26.0` 之前的两个 derender 端点会接受调用方提交的嵌套 `GenerateResponse`,并在资源边界校验前执行去分词、logprob 处理和 OpenAI 兼容响应构造。拥有相应 API 访问权限的客户端因此可用格式合法、但规模异常的 JSON 竞争 CPU、内存、响应缓冲与带宽,影响同一服务上的其他请求。
vLLM 在 0.26.0 之前的两个 derender 端点会接受调用方提交的嵌套 GenerateResponse,并在资源边界校验前执行去分词、logprob 处理和 OpenAI 兼容响应构造。拥有相应 API 访问权限的客户端因此可用格式合法、但规模异常的 JSON 竞争 CPU、内存、响应缓冲与带宽,影响同一服务上的其他请求。
下文沿 HTTP 输入、服务层、tokenizer.decode 与响应序列化的路径梳理根因、静态分析证据、上游补丁和安全验证方式,并讨论低权限 API 凭据、内部辅助路由暴露和多租户模型网关中的攻击价值。攻击者不需要读取数据或执行代码,也可能把可控 JSON 的规模转换为共享服务的可用性风险。处置重点很明确:升级到 vLLM 0.26.0 或更高版本,按端点收窄 derender 的可达范围,并在网关与应用层共同设置资源边界。该问题是认证后拒绝服务(CWE-400/CWE-770),不是 RCE、数据读取或数据篡改漏洞。
一、先给结论:谁需要处理、该怎么处理
CVE-2026-71486 的优先级取决于一件很具体的事:运行中的 vLLM 是否暴露了 derender 路由,以及谁能调用它。若实例版本低于 0.26.0,并且调用方能到达这两个端点,就应将升级和路由收敛纳入近期变更窗口。公告将受影响范围标为 vLLM < 0.26.0,修复版本为 0.26.0;漏洞评分为 4.3(MEDIUM),向量为 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L。GitHub Advisory
问题 | 结论 |
受影响对象 | vLLM |
入口 |
|
攻击前提 | 攻击者能够访问接口;官方 CVSS 以低权限 API 访问( |
影响 | CPU、内存、响应体与带宽竞争,可降低同一服务的可用性 |
修复 | 升级至 vLLM |
紧急缓解 | 无需求时在网关拒绝 derender;有需求时仅向可信内部调用方开放,并限制 body、并发与速率 |
这里的 PR:L 不应被误读为“每个部署都一定有 API key”。它描述的是官方评分采用的攻击条件:常规、低权限的 API 访问已经足以影响可用性。未配置认证的实例、服务网格内部可达的实例,或把同一凭据授予所有 /v1 路由的实例,都需要按自己的真实边界重新评估。
处置时先做三项盘点:确认镜像或 Python 包的实际版本;列出网关、Ingress 与服务网格中是否发布了两个 derender 路由;确定调用身份、每 key 配额和后端 worker 是否与普通生成流量共享。只要其中一项不清楚,就不宜把“内部辅助接口”当作天然不受攻击面管理的接口。
短期内,最有效的风险收敛通常是禁用不需要的路由,这会直接消除入口。必须使用时,不应只依赖通用 API key 鉴权;应通过服务身份或网络策略将其限定为 render worker 等可信调用者。完成升级后,用一条有界的负向测试确认超限输入会在 decode 前被拒绝。
二、漏洞背景:为什么一个 derender 接口值得关注
2.1 derender 在请求链中的位置
derender 的职责不是发起模型推理,而是把已生成的结果转换成 OpenAI 兼容响应。其工作包括 token 解码、logprob 转换,以及在 chat 情况下可选的工具或推理 parser 处理。正常生成路径通常会受到模型上下文、输出 token 预算和服务端并发序列数的约束;derender 则接收调用方回传的“生成结果形状”。当该形状可以从 HTTP JSON 直接进入后处理时,原先由生成器施加的预算不会自动随对象一起继承。
这正是该 CVE 的工程教训:辅助、scale-out 或内部接口一旦穿过网关、Ingress 或服务网格,就应被视为生产 API。鉴权只能回答“谁能调用”,不能回答“单次调用最多能让后端做多少工作”。资源边界必须独立、显式地定义。
2.2 风险模型与边界
这是一类认证后远程 DoS,而非内存破坏、命令执行、越权读取或数据篡改。可控 JSON 的嵌套基数会转化为去分词、对象分配、响应序列化和网络传输的工作量。实际后果受 tokenizer、可用内存、worker 数量、反向代理的 body 限制、并发控制和负载形态影响;合理的表述是“可造成资源竞争或可用性下降”,而非声称任一请求必然导致 OOM 或节点崩溃。
2.3 低权限 API 访问的业务价值
攻击价值 | 源码与部署层面的原因 | 可能的业务后果 |
绕过常规生成预算 | derender 接收回传 | 主生成 API 即使限制了 |
放大共享资源竞争 | decode、parser、logprob 转换与 JSON 序列化在 API 前端或后处理服务执行 | 正常请求排队、延迟升高或超时 |
推高边缘层成本 | 大响应还会经过 ASGI worker、代理缓冲、访问日志和出口网络 | 网关排队、可观测性成本或带宽争用 |
扩大凭据泄露影响 | 低权限凭据未必能读取敏感数据,却可能访问未隔离的辅助路由 | 一枚泄露凭据成为可用性风险 |
2.4 三类需要重点审查的部署
应优先盘点三类部署:derender 与生成流量共用前端和配额的多租户模型网关;被 Ingress、网关或服务网格一并发布的 scale-out 辅助路由;以及普通 API key 或服务身份可访问全部 /v1/* 路由的环境。它们分别对应共享可用性、路由暴露和凭据影响面扩大。
2.5 从输入到业务影响的传导链
前半段的路由、schema 与处理函数有源码依据;后半段则是需要结合部署拓扑与监控验证的影响推断。两者必须分开看。
三、攻击面建模:低权限接口如何变成可用性风险
前两章已经说明:CVE-2026-71486 不是代码执行或数据泄露问题,而是认证后可用性风险。要判断它在真实环境中的价值,关键不在于“payload 能不能写得很大”,而在于 derender 路由是否处在共享资源边界上,以及调用者能否用低权限身份触达这条边界。
下面将抽象的资源消耗问题落到部署场景。所有结论都有前提:旧版 vLLM、可达的 derender 路由、调用权限和不足的端点级限额同时存在时,攻击面才完整。
3.1 攻击者画像
对这个漏洞而言,攻击者不需要控制模型、不需要构造特殊 token,也不需要能读取其他租户数据。更现实的画像通常是“能调用 API,但不应被允许无限消耗共享后处理资源”的主体。
攻击者类型 | 典型来源 | 需要关注的原因 |
外部低权限租户 | 多租户模型网关、开放平台、按 key 计费的推理服务 | 权限合法,但其请求可能与其他租户共享 worker、代理和带宽 |
内部普通服务 | 内部调用方、自动化任务、评测平台或批处理 worker | 内部网络可达常被误当作安全边界,辅助路由可能缺少独立配额 |
泄露的普通 API key | CI 日志、测试脚本、浏览器配置、服务配置文件 | 泄露凭据未必能读数据,却可能触发可用性影响 |
权限过宽的服务身份 | 被授予 | 主生成端点受限时,辅助端点可能仍被同一身份访问 |
这也是该 CVE 的现实价值:低权限并不等于低影响。对于共享 API 前端,调用者身份只回答“谁能发请求”,不能回答“单次请求最多能让后端做多少 decode、对象复制和序列化”。
3.2 前置条件矩阵
风险评估可以从下面这张矩阵开始。任一条件不成立,攻击链就会被削弱或中断;条件越多同时成立,优先级越高。
条件 | 如何确认 | 风险含义 |
vLLM 版本 | 运行中镜像、Python 包版本、启动日志或 SBOM | 应用层 derender 边界缺失 |
暴露 | Ingress、API gateway、服务网格、路由表和访问日志 | 外部或内部调用方可触达入口 |
调用者具备 API 访问 | API key、服务身份、mTLS 身份或内部网络策略 | 符合官方 CVSS 的低权限访问前提 |
derender 与普通生成共享资源 | 同一进程、worker 池、反向代理、日志系统或出口带宽 | 单个调用方可能影响其他请求 |
缺少端点级限额 | body size、JSON 深度、每 key 速率、并发、超时和响应大小 | 传输层和网关无法在应用前削峰 |
未做可观测性拆分 | 指标未按 path、key、status、decode 时长、响应大小聚合 | 事后难以区分正常流量与异常后处理成本 |
这份矩阵把“是否受影响”拆成了可验证的问题。除了“是否有 API key”,还要确认该 key 能否访问 derender、derender 是否有独立限额,以及后端是否与主业务共享资源。
3.3 场景 A:多租户模型网关中的 noisy-neighbor
多租户推理平台常用同一网关或 API 前端承载多个团队、项目或外部客户。常规生成接口通常会配置 max_tokens、并发、每 key 速率和计费策略;但辅助接口如果被作为 /v1 表面的一部分发布,可能没有被纳入同一套预算。
在这种场景下,攻击价值不在数据访问,而在共享可用性:
这类影响通常表现为排队、超时、错误率升高或 SLO 违约,而非稳定、可重复的节点崩溃。把它描述为“可将调用方控制的嵌套规模转化为共享资源竞争”比“必然 OOM”更准确。
3.4 场景 B:scale-out 辅助 API 被统一发布
derender 常被理解为 render / scale-out 工作流中的内部步骤。问题在于,内部接口经过 Ingress、API gateway、服务网格或统一认证层后,可能被无意间发布到比设计预期更大的网络边界。
这种场景下,优先检查三个问题:
检查项 | 期望状态 | 风险状态 |
路由可达性 | derender 仅允许可信 render worker 或后处理服务调用 | 与普通 |
身份边界 | 使用服务身份、mTLS、路由白名单或内部网段策略 | 普通 API key 或通配服务身份即可调用 |
配额边界 | derender 有独立 body、并发、速率和响应大小限制 | 只对主生成端点配置限额 |
这里的工程教训很直接:内部不是一个位置,而是一组可执行的策略。只要路由穿过发布边界,它就应该被当作生产 API 管理。
3.5 场景 C:普通调用凭据泄露后的影响面扩大
许多组织在凭据泄露评估中会优先问两个问题:能否读取数据,能否发起高成本推理。CVE-2026-71486 提醒我们还要问第三个问题:这枚凭据是否能访问辅助后处理接口,并让共享前端承担非预期工作。
一条典型事故链可以写成:
这并不表示每个泄露 key 都能触发该 CVE。真正需要验证的是:key 的权限范围、路由白名单、端点级配额、请求体限制和后端资源隔离是否共同阻断这条链。
3.6 攻击收益与限制
该漏洞的影响边界如下:它可以造成共享资源竞争,但不赋予调用方代码执行、数据访问或权限提升能力。
维度 | 更准确的表述 |
可造成的影响 | CPU、内存、响应缓冲、JSON 序列化、日志和出口带宽竞争 |
业务侧表现 | 延迟升高、排队、请求超时、错误率上升、自动扩缩容或观测成本增加 |
权限前提 | 官方评分按低权限 API 访问评估;具体部署可能是 API key、服务身份或内部网络可达 |
不具备的能力 | RCE、模型权重泄露、跨租户数据读取、权限提升或稳定崩溃所有节点 |
无法据此推导的结论 | 某个固定大小输入必然压垮所有生产环境 |
这个边界很重要。DoS 类漏洞的技术深度不来自夸大破坏性,而来自解释清楚:哪些资源被消耗,消耗如何与输入规模相关,哪些部署条件会把局部成本放大成业务影响。
3.7 从请求到业务影响的传播路径
在真实服务中,资源影响很少只停留在一个函数里。derender 的后处理结果还会经过响应模型、Web 框架、代理和观测系统。
其中前半段可以通过源码和静态分析直接验证;后半段属于部署侧影响推断,需要结合实际 worker 拓扑、网关配置和运行指标确认。
四、入口信任边界:从 HTTP JSON 到服务层转交
底层分析的第一步,不是直接看 tokenizer.decode(),而是确认调用方控制的数据如何进入程序,以及中途有哪些检查没有承担资源边界的职责。CVE-2026-71486 的关键点正是在这里:修复前代码拥有若干“正确性检查”,但缺少位于资源 sink 之前的“规模检查”。
4.1 derender 信任了“结果形状”,但结果来自请求体
常规生成接口的资源预算通常发生在推理之前:模型上下文长度、输出 token 上限、并发序列数量和调度器容量,会共同限制单次请求能占用多少 GPU、CPU 和内存资源。derender 的语义不同。它不负责生成 token,而是接收一份“已经生成好的结构”,再把这份结构转成 OpenAI 兼容响应。
这个语义差异容易制造代码阅读陷阱:GenerateResponse 这个名字像是“服务端生成的可信结果”,但在 derender API 中,它是由客户端通过 JSON body 提交的输入。
只要对象跨过 HTTP 边界,服务端就不能再沿用“它已经由生成器约束过”的假设。每个会影响后处理成本的维度,都需要在进入昂贵操作前重新计量。
4.2 两条入口,汇入同一类服务层路径
源码中有两个 derender HTTP 入口,分别处理 chat 和 completion 响应:
chat 路径处理单个 generate_response,completion 路径处理 generate_responses 列表;两者最终都会遍历 choices,并对每个非空 token_ids 调用 tokenizer。差异在于 completion 多一层 response 循环,chat 在特定配置下还可能进入 parser 分支。
下面是修复前 api_router.py 中两条路由的完整 handler 上下文。request: Derender*Request 是 FastAPI 的 body 参数;handler.derender_*_response(request) 是将该对象交给服务层的显式调用;无论成功还是错误结果,最后都会经 model_dump() 交给 JSONResponse。因此这段代码同时固定了 HTTP Source、service handoff 和响应序列化三个位置。
router = APIRouter()
def derender(request: Request) -> ServingDerender | None:
return getattr(request.app.state, "serving_derender", None)
@router.post(
"/v1/chat/completions/derender",
dependencies=[Depends(validate_json_request)],
response_model=ChatCompletionResponse,
)
async def derender_chat_completion(
request: DerenderChatRequest, raw_request: Request
):
handler = derender(raw_request)
if handler is None:
raise NotImplementedError(
"The model does not support Chat Completions Derender API"
)
result = await handler.derender_chat_response(request)
if isinstance(result, ErrorResponse):
return JSONResponse(content=result.model_dump(), status_code=result.error.code)
return JSONResponse(content=result.model_dump())
@router.post(
"/v1/completions/derender",
dependencies=[Depends(validate_json_request)],
response_model=CompletionResponse,
)
async def derender_completion(
request: DerenderCompletionRequest, raw_request: Request
):
handler = derender(raw_request)
if handler is None:
raise NotImplementedError("The model does not support Completions Derender API")
result = await handler.derender_completion_response(request)
if isinstance(result, ErrorResponse):
return JSONResponse(content=result.model_dump(), status_code=result.error.code)
return JSONResponse(content=result.model_dump())对应到本仓库的修复前源码快照,可以把入口坐标固定为下表:
代码位置 | 作用 | 安全含义 |
| 声明 | chat derender 的 HTTP Source |
| 声明 | completion derender 的 HTTP Source |
| route handler 参数为 | FastAPI / Pydantic 会按请求模型绑定 JSON body |
| route handler 参数为 | completion 请求体被绑定为嵌套 Pydantic 对象 |
| 调用 | 请求对象进入服务层 |
| 调用 | 请求对象进入服务层 |
|
| 响应对象会再次被导出和序列化 |
这些行号不是装饰性引用。它们把“外部请求可达”与“响应会被序列化”都落到了具体代码上,为后面分析 Source 和最终响应成本提供锚点。
4.3 schema 校验没有表达资源预算
修复前的请求模型已经使用 Pydantic 类型,这会让代码看起来“有校验”。但这里校验的是 JSON 形状,不是资源预算。以下是参与该路径的完整字段定义(省略了只解释字段语义的 docstring,不省略字段、嵌套关系或 validator):
class GenerateResponseChoice(BaseModel):
index: int
logprobs: ChatCompletionLogProbs | None = None
finish_reason: str | None = "stop"
token_ids: list[int] | None = None
routed_experts: str | None = None
class GenerateResponse(BaseModel):
request_id: str = Field(default_factory=lambda: f"{random_uuid()}")
model: str | None = None
created: int | None = None
choices: list[GenerateResponseChoice]
usage: UsageInfo | None = Field(default=None)
prompt_logprobs: list[dict[int, Logprob] | None] | None = None
kv_transfer_params: dict[str, Any] | None = Field(default=None)
class DerenderChatRequest(BaseModel):
model: str
generate_response: GenerateResponse
prompt_tokens: int | None = None
chat_request: ChatCompletionRequest | None = None
class DerenderCompletionRequest(BaseModel):
model: str
generate_responses: list[GenerateResponse]
prompt_tokens: list[int] | None = None
completion_request: CompletionRequest | None = None
@model_validator(mode="after")
def _validate_prompt_tokens_length(self) -> "DerenderCompletionRequest":
if self.prompt_tokens is not None and len(self.prompt_tokens) != len(
self.generate_responses
):
raise ValueError(
f"prompt_tokens length ({len(self.prompt_tokens)}) must equal "
f"generate_responses length ({len(self.generate_responses)})"
)
return selflist[int] 可以拒绝“元素不是整数”的输入,却不会因为列表太长而自动失败;list[GenerateResponse] 可以描述数组元素结构,却不会表达“最多允许多少个 response”。同理,logprobs.content 和 top_logprobs 的形状合法,并不意味着它们的规模适合在当前请求预算中处理。
这就是本漏洞最容易被低估的地方:格式合法的输入,仍然可能是不安全的输入。对资源消耗类漏洞而言,schema 的问题往往不是“没有类型”,而是“类型没有携带上限”。
修复前 schema 的关键坐标如下:
代码位置 | 字段 | 缺失的资源语义 |
|
| choice 内可携带 token IDs、logprobs 和相邻可控字段 |
|
| 没有绑定 |
|
| 输出结构被表示为可嵌套对象 |
|
| 没有绑定 |
|
| 单个 caller-supplied |
|
| completion 外层 response 数没有上限 |
4.4 validate_json_request 不是 body 边界
路由层依赖 validate_json_request,但在这个源码快照中,它承担的是 Content-Type 检查,而不是 body 字节数、JSON 深度或嵌套字段基数限制。可以把它归类为传输格式检查,而不是资源预算检查。
该依赖的函数体只有如下检查;它没有读取 body,也没有对 JSON 或任一 Pydantic 字段计数:
async def validate_json_request(raw_request: Request):
content_type = raw_request.headers.get("content-type", "").lower()
media_type = content_type.split(";", maxsplit=1)[0]
if media_type != "application/json":
raise RequestValidationError(
errors=["Unsupported Media Type: Only 'application/json' is allowed"]
)因此,修复后仍应保留网关或 WAF 限制。服务层补丁可以阻断 decode、parser 和响应序列化,却无法撤销 HTTP 接收、JSON 解析和 Pydantic 物化已经产生的成本。
4.5 服务层只检查 model,没有检查嵌套规模
进入服务层后,修复前代码会先调用 _check_model(request)。这个检查是必要的,但它只回答“请求中的 model 是否可用”,不能限制下游工作量。
chat 路径的完整关键控制流如下。注意 gen 在调用 derenderer 之后才用于 token 统计和响应字段;修复前在 _check_model() 与 derender_chat() 之间没有规模 barrier。
async def derender_chat_response(
self, request: DerenderChatRequest
) -> ChatCompletionResponse | ErrorResponse:
error_check_ret = await self._check_model(request)
if error_check_ret is not None:
return error_check_ret
try:
choices = await self.online_derenderer.derender_chat(
request.generate_response, request.chat_request
)
except ValueError as exc:
return self.create_error_response(str(exc))
prompt_tokens = request.prompt_tokens if request.prompt_tokens is not None else 0
gen = request.generate_response
completion_tokens = sum(len(ch.token_ids) for ch in gen.choices if ch.token_ids)
usage = UsageInfo(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
return ChatCompletionResponse(
id=gen.request_id,
model=request.model,
created=int(time.time()),
choices=choices,
usage=usage,
prompt_logprobs=gen.prompt_logprobs,
kv_transfer_params=gen.kv_transfer_params,
)这里 request.generate_response 在 model 检查后立即进入 derenderer。后续 completion_tokens 统计还会再次遍历 choices;prompt_logprobs 也会随响应对象进入序列化流程。即使不考虑 parser 和 logprob 解析,服务层本身仍会把部分可控容器带入响应构造。
completion 路径的问题更能说明“检查时机”本身就是安全属性。下列连续控制流展示了空列表检查确实位于 derender_completion() 返回之后:
async def derender_completion_response(
self, request: DerenderCompletionRequest
) -> CompletionResponse | ErrorResponse:
error_check_ret = await self._check_model(request)
if error_check_ret is not None:
return error_check_ret
choices, total_prompt_tokens, total_completion_tokens = (
await self.online_derenderer.derender_completion(
request.generate_responses, request.prompt_tokens
)
)
if not request.generate_responses:
return self.create_error_response("generate_responses must not be empty")
first = request.generate_responses[0]
usage = UsageInfo(
prompt_tokens=total_prompt_tokens,
completion_tokens=total_completion_tokens,
total_tokens=total_prompt_tokens + total_completion_tokens,
)
return CompletionResponse(
id=first.request_id,
model=request.model,
created=int(time.time()),
choices=choices,
usage=usage,
kv_transfer_params=first.kv_transfer_params,
)空列表检查位于 derender 调用之后。这个例子不表示空列表本身会造成资源耗尽,而是说明旧代码的校验顺序没有以资源 sink 为中心设计:先转交给昂贵处理,再做部分语义判断。对过大的非空列表而言,这种顺序无法提供保护。
4.6 prompt_tokens 对齐检查的含义
completion derender 中还有一个容易被忽略的细节。这个函数开头建立 n、默认 prompt_tokens_list,随后才开始 response × choice 的两层循环:
async def derender_completion(
self,
generate_responses: list[GenerateResponse],
prompt_tokens: list[int] | None = None,
) -> tuple[list[CompletionResponseChoice], int, int]:
n = len(generate_responses)
prompt_tokens_list: list[int] = (
prompt_tokens if prompt_tokens is not None else [0] * n
)
tokenizer = self.renderer.get_tokenizer()
choices: list[CompletionResponseChoice] = []
total_prompt_tokens = 0
total_completion_tokens = 0
index = 0
for gen, pt in zip(generate_responses, prompt_tokens_list):
for choice in gen.choices:
if not choice.token_ids:
raise ValueError(
f"choice {choice.index} in response {gen.request_id} "
"has empty or null token_ids"
)
decoded_text = tokenizer.decode(
choice.token_ids, skip_special_tokens=True
)
completion_logprobs = None
if choice.logprobs is not None:
resolved = _resolve_logprobs(choice.logprobs, tokenizer)
completion_logprobs = _convert_chat_logprobs_to_completion_logprobs(
resolved
)
choices.append(
CompletionResponseChoice(
index=index,
text=decoded_text,
finish_reason=choice.finish_reason,
logprobs=completion_logprobs,
)
)
total_completion_tokens += len(choice.token_ids)
index += 1
total_prompt_tokens += pt
return choices, total_prompt_tokens, total_completion_tokens修复前的 Pydantic model validator 已经要求:如果调用方提供 prompt_tokens,它的长度必须等于 generate_responses。这避免了 zip() 静默截断导致 usage 统计错位。若未提供 prompt_tokens,代码会创建长度为 n 的 [0] * n,这本身也随 generate_responses 增长。
这不是 CVE 的主 sink,却说明旧代码已有“一致性”校验,缺少的则是“规模”校验。开发者处理了列表之间的语义关系,但没有将列表长度纳入资源预算。资源消耗漏洞往往不是因为代码完全粗糙,而是因为正确性校验与可用性校验被混为一谈。
4.7 Source、Barrier 与 Sink 的精确划分
把这条链拆成 Source、Barrier 和 Sink,可以避免把“代码里出现了校验”误读成“校验覆盖了漏洞”。
类别 | 代码位置 / 行为 | 安全含义 |
Source | FastAPI 将 JSON body 绑定为 | 外部输入被物化为 Pydantic 对象和嵌套 Python 容器 |
Source |
| 调用方控制的结果对象跨过 HTTP 到应用边界 |
非充分 Barrier |
| 仅检查 Content-Type,不能约束 body 字节数、JSON 深度或字段基数 |
非充分 Barrier |
| 只检查 model 语义,不能限制 response、choice、token 或 logprob 规模 |
非充分 Barrier |
| 保证两个列表对齐,但没有限制列表最大长度 |
非充分 Barrier |
| 拒绝空值,但不拒绝超长非空列表 |
Sink |
| token 数量转换为 CPU、字符串分配和后续响应大小 |
Sink |
| 完整解码文本和 token IDs 继续进入工具 / 推理解析逻辑 |
Sink |
| 嵌套 logprob 容器转换为循环、模型复制和字典构造 |
Sink |
| 已构造响应再次转换为可序列化对象并写出 JSON |
这里的关键不是“没有 Barrier”,而是旧 Barrier 的属性不匹配:它们检查类型、Content-Type、model 名称或列表对应关系,却没有在第一处资源 sink 前检查规模上限。资源消耗漏洞经常就藏在这种错位里。
4.8 为什么 render 阶段预算不能自动保护 derender
render 路径确实会计算并约束正常生成请求的预算。例如 chat 和 completion render 服务都会通过 get_max_tokens() 把 max_model_len、输入长度、请求中的 max_tokens / max_completion_tokens、默认采样参数和平台上限合并起来,得到本次生成允许的输出 token 上限。函数先拒绝超过模型上下文的输入,再返回各个候选上限的最小值:
def get_max_tokens(
max_model_len: int,
max_tokens: int | None,
input_length: int,
default_sampling_params: dict,
override_max_tokens: int | None = None,
truncate_prompt_tokens: int | None = None,
) -> int:
if truncate_prompt_tokens is not None:
limit = truncate_prompt_tokens
input_length = min(
input_length,
max_model_len if limit == -1 else limit,
)
if max_model_len < input_length:
raise ValueError(
f"Input length ({input_length}) exceeds model's maximum "
f"context length ({max_model_len})."
)
model_max_tokens = max_model_len - input_length
platform_max_tokens = current_platform.get_max_output_tokens(input_length)
fallback_max_tokens = (
max_tokens
if max_tokens is not None
else default_sampling_params.get("max_tokens")
)
return min(
val
for val in (
model_max_tokens,
fallback_max_tokens,
override_max_tokens,
platform_max_tokens,
)
if val is not None
)采样参数中的 n 也会受 VLLM_MAX_N_SEQUENCES 约束。
但 derender 接口不是直接消费进程内、不可篡改的 render 返回值。它重新从 HTTP 接收 GenerateResponse,并允许调用方提交 token_ids、choices、generate_responses 和 logprob 结构。DerenderChatRequest.chat_request / DerenderCompletionRequest.completion_request 提供的是 parser 所需的上下文;prompt_tokens 主要服务于 usage 统计。修复前,这些字段没有被用来证明 GenerateResponse 与某次已受限 render 输出严格对应,也没有重新计算“这份回传结果是否仍在模型和服务预算内”。
因此,问题不是 vLLM 完全没有生成预算,而是预算没有在 derender 这个新的信任边界处重新生效。只要结果对象跨过 HTTP 边界,原本在 render / sampling / scheduler 内部成立的约束,就必须被显式带回来。
4.9 三层边界各自负责什么
这个漏洞也说明了传输层、schema 层和业务层边界的分工。
层次 | 应回答的问题 | 对本漏洞的意义 |
传输层 / 网关 | 请求体是否能进入进程,速率和并发是否可接受 | 限制 body 字节数、JSON 深度、请求频率,降低解析前成本 |
schema 层 | 字段类型和基本语义是否合法 | 拒绝错误形状、错误类型、负 token ID 等不一致输入 |
业务层 | 昂贵操作前,字段规模是否符合模型和服务预算 | 在 decode、parser、logprob 转换前限制 token、choice、response 和 logprob 数量 |
上游补丁主要修复的是业务层边界:在服务层调用 derenderer 之前加入 _validate_derender_bounds()。它无法撤销 HTTP body 接收、JSON 解析和 Pydantic 对象构造已经产生的成本,但可以阻断后续更昂贵、也更接近核心服务资源的 decode、parser、对象复制和响应序列化。
部署侧仍需要网关或 WAF 的请求体限制:补丁回答“合法字段能否进入 derender 后处理”,网关回答“异常大的输入是否应进入应用进程”。两者互补,不能互相替代。
五、资源 sink 深挖:OnlineDerenderer 如何放大可控结构
外部 JSON 穿过入口和服务层后,会在 OnlineDerenderer 中转化为 CPU、内存、对象图、响应体和下游网络成本。
核心答案是:攻击者控制的不是某个特殊 token 值,而是一组乘法因子。generate_responses 决定 completion 外层循环,choices 决定解码次数和响应对象数量,token_ids 决定单次 decode 输入和输出文本,logprob 结构决定额外的嵌套遍历、模型复制和序列化规模。
先把核心函数和已确认 sink 坐标列出来:
代码位置 | 函数 / 调用点 | 在漏洞链中的角色 |
|
| chat derender 主处理函数 |
| parser 分支 | caller-controlled token IDs 到达 decode |
| plain chat 分支 | 无 parser 时的主 decode sink |
|
| completion derender 主处理函数 |
| completion 分支 | response × choice 循环内的 decode sink |
|
| logprob 结构解析和对象复制 |
| 遍历 | logprob 外层循环 |
| 遍历 | top candidates 内层循环 |
|
| completion 风格 logprob 容器重建 |
| 构造 | logprob 输出对象放大 |
资源消耗并不集中在单独一行,而是由多个线性和嵌套处理点共同构成。decode 是最直观的 sink;logprob 转换和响应序列化则说明,只盯着 tokenizer 会低估影响面。
5.1 可控输入面的四个维度
修复前 schema 对字段形状和元素类型做了描述,却没有为这些维度建立业务上限。
字段 / 结构 | 可控维度 | 会放大的工作 |
| completion 请求中的 response 数量 | completion 外层遍历次数、聚合响应规模 |
| 每个 response 的 choice 数量 | choice 循环次数、响应 choice 对象数量 |
| 单个 choice 的 token 数 |
|
| 嵌套列表 / 字典规模 | logprob 遍历、token placeholder 解析、对象复制和序列化 |
这些维度可以互相叠加。单个 choice.token_ids 过长会放大一次 decode;很多 choice 会放大 decode 次数;很多 response 又会放大 completion 的外层循环。logprob 结构虽然不是主路径上的唯一 sink,但会给同一请求叠加嵌套遍历和对象分配。
5.2 chat plain 分支:每个 choice 一次 decode
下面是 chat 的完整处理函数。它先取得 tokenizer 和响应容器,逐个处理 generate_response.choices;每个 choice 先走 logprob 解析,再按 parser 是否可用选择解析分支或 plain decode 分支,最后将文本和 logprob 一同保存在 ChatCompletionResponseChoice。这样 token_ids、logprobs 和响应对象的关系不依赖函数外上下文即可读出。
async def derender_chat(
self,
generate_response: GenerateResponse,
chat_request: ChatCompletionRequest | None = None,
) -> list[ChatCompletionResponseChoice]:
tokenizer = self.renderer.get_tokenizer()
choices: list[ChatCompletionResponseChoice] = []
for choice in generate_response.choices:
if not choice.token_ids:
raise ValueError(f"choice {choice.index} has empty or null token_ids")
resolved_logprobs = (
_resolve_logprobs(choice.logprobs, tokenizer)
if choice.logprobs is not None
else None
)
if self.parser is not None and chat_request is not None:
decoded_text = tokenizer.decode(
choice.token_ids, skip_special_tokens=False
)
chat_template_kwargs: dict[str, Any] = {}
if not self.use_harmony:
chat_template_kwargs = (
chat_request.build_chat_params(
self.chat_template,
self.chat_template_content_format,
)
.with_defaults(self.default_chat_template_kwargs)
.chat_template_kwargs
)
parser = self.parser(
tokenizer,
chat_request.tools,
chat_template_kwargs=chat_template_kwargs,
)
reasoning, content, tool_calls = parser.parse(
decoded_text,
chat_request,
enable_auto_tools=self.enable_auto_tools,
model_output_token_ids=choice.token_ids,
)
if not getattr(chat_request, "include_reasoning", True):
reasoning = None
tc_items = (
[ToolCall(id=random_uuid(), function=tc) for tc in tool_calls]
if tool_calls
else []
)
message = ChatMessage(
role="assistant",
reasoning=reasoning,
content=content,
tool_calls=tc_items,
)
else:
decoded_text = tokenizer.decode(
choice.token_ids, skip_special_tokens=True
)
message = ChatMessage(role="assistant", content=decoded_text)
choices.append(
ChatCompletionResponseChoice(
index=choice.index,
message=message,
logprobs=resolved_logprobs,
finish_reason=choice.finish_reason,
)
)
return choices这里的 if not choice.token_ids 只能拒绝 None 或空列表;一个很长的非空列表仍然满足条件并进入 decode。安全意义上的边界应该是“进入 decode 前长度不得超过业务上限”,而不是“不能为空”。
decode 之后,decoded_text 不是临时值。它会被放入 ChatMessage.content,再被保存在响应 choices 中,最后被 model_dump() 和 JSONResponse 序列化。也就是说,成本不只发生在 tokenizer 内部,还会沿响应对象继续传播。
从源码关系看,服务层还会在 chat 返回前计算 completion_tokens = sum(len(ch.token_ids) for ch in gen.choices if ch.token_ids),并把 gen.prompt_logprobs 放入 ChatCompletionResponse。这意味着部分调用方控制的容器不仅影响 derenderer 内部处理,也会继续影响 usage 统计和最终响应对象。
5.3 chat parser 分支:完整文本继续进入解析器
在上面完整函数的 parser 分支中,self.parser 先以 tokenizer、工具定义和模板参数构造实例,再把保留 special tokens 的 decoded_text 与原始 token IDs 同时交给 parse():
parser = self.parser(
tokenizer,
chat_request.tools,
chat_template_kwargs=chat_template_kwargs,
)
decoded_text = tokenizer.decode(
choice.token_ids, skip_special_tokens=False
)
reasoning, content, tool_calls = parser.parse(
decoded_text,
chat_request,
enable_auto_tools=self.enable_auto_tools,
model_output_token_ids=choice.token_ids,
)这个分支的安全含义有两层。第一,skip_special_tokens=False 会把完整解码文本交给后续逻辑,文本长度仍由 token_ids 规模驱动。第二,parser 同时收到 decoded_text 和原始 choice.token_ids,因此 decode 之后的成本还可能扩展到 reasoning / tool call 解析。
这不是另一种漏洞类型,也不意味着 parser 自身存在越界问题。parser 是同一资源放大链上的额外 sink:当部署启用相关能力时,超限 token 列表不仅触发 decode,还会继续触发解析工作。
5.4 completion 分支:response 与 choice 相乘
completion derender 的完整函数如下。n 同时决定默认的 [0] * n 列表大小,外层 zip() 遍历每个 response,内层循环遍历该 response 的 choices;每一次内层迭代都会 decode、可选转换 logprob、构造一个响应 choice 并累计 token 数。
async def derender_completion(
self,
generate_responses: list[GenerateResponse],
prompt_tokens: list[int] | None = None,
) -> tuple[list[CompletionResponseChoice], int, int]:
n = len(generate_responses)
prompt_tokens_list: list[int] = (
prompt_tokens if prompt_tokens is not None else [0] * n
)
tokenizer = self.renderer.get_tokenizer()
choices: list[CompletionResponseChoice] = []
total_prompt_tokens = 0
total_completion_tokens = 0
index = 0
for gen, pt in zip(generate_responses, prompt_tokens_list):
for choice in gen.choices:
if not choice.token_ids:
raise ValueError(
f"choice {choice.index} in response {gen.request_id} "
"has empty or null token_ids"
)
decoded_text = tokenizer.decode(
choice.token_ids, skip_special_tokens=True
)
completion_logprobs = None
if choice.logprobs is not None:
resolved = _resolve_logprobs(choice.logprobs, tokenizer)
completion_logprobs = _convert_chat_logprobs_to_completion_logprobs(
resolved
)
choices.append(
CompletionResponseChoice(
index=index,
text=decoded_text,
finish_reason=choice.finish_reason,
logprobs=completion_logprobs,
)
)
total_completion_tokens += len(choice.token_ids)
index += 1
total_prompt_tokens += pt
return choices, total_prompt_tokens, total_completion_tokens这里存在两个乘法因子:response 数 R 和每个 response 的 choice 数 C_i。即使单个 token_ids 长度被控制,如果 R 或 C_i 没有限制,总 decode 次数和响应对象数量仍可增长。因此补丁不能只检查 token_ids,还必须限制 generate_responses 和 choices。
completion 路径还会计算 usage,并将所有 choice 聚合到单个 completion 响应中。聚合响应越大,后续 model_dump()、JSON 编码、代理缓冲和网络写出的成本也越高。
5.5 logprob 是叠加型资源放大点
logprobs 相关处理不是另一条独立入口,但它是同一请求内可以叠加的次级 sink。下面是 _resolve_logprobs() 的完整函数:先对 content 做空值短路,再维护 token 上下文并遍历每个 entry;每个 entry 内部都要遍历 top_logprobs、解析 placeholder 并复制 Pydantic 对象。
def _resolve_logprobs(
logprobs: ChatCompletionLogProbs, tokenizer: TokenizerLike
) -> ChatCompletionLogProbs:
if logprobs.content is None:
return logprobs
context_token_ids: list[int] = []
resolved_content = []
for entry in logprobs.content:
token_str, token_bytes = resolve_token_id_placeholder(entry.token, tokenizer)
sampled_id = _parse_token_id_placeholder(entry.token)
if token_str.endswith("�") and sampled_id is not None:
token_str = _correct_decoded_token(sampled_id, context_token_ids, tokenizer)
token_bytes = list(token_str.encode("utf-8"))
resolved_top = []
for top in entry.top_logprobs:
top_str, top_bytes = resolve_token_id_placeholder(top.token, tokenizer)
top_id = _parse_token_id_placeholder(top.token)
if top_str.endswith("�") and top_id is not None:
top_str = _correct_decoded_token(top_id, context_token_ids, tokenizer)
top_bytes = list(top_str.encode("utf-8"))
resolved_top.append(
top.model_copy(update={"token": top_str, "bytes": top_bytes})
)
resolved_content.append(
entry.model_copy(
update={
"token": token_str,
"bytes": token_bytes,
"top_logprobs": resolved_top,
}
)
)
if sampled_id is not None:
context_token_ids.append(sampled_id)
return ChatCompletionLogProbs(content=resolved_content)如果 token placeholder 需要修正,上一个函数会调用 _correct_decoded_token()。该函数把前面收集的 token IDs 截为最多四个 token 的窗口,并在循环中多次调用 tokenizer;完整函数如下:
def _correct_decoded_token(
token_id: int, context_token_ids: list[int], tokenizer: TokenizerLike
) -> str:
max_ctx = min(len(context_token_ids), 4)
for num_ctx in range(1, max_ctx + 1):
context = context_token_ids[-num_ctx:]
full_decoded = tokenizer.decode(context + [token_id])
if full_decoded.endswith("�"):
continue
clean_end = len(context)
for j in range(len(context) - 1, -1, -1):
if tokenizer.decode([context[j]]).endswith("�"):
clean_end = j
else:
break
clean_prefix = tokenizer.decode(context[:clean_end]) if clean_end > 0 else ""
if full_decoded.startswith(clean_prefix):
return full_decoded[len(clean_prefix) :]
common_len = 0
for a, b in zip(clean_prefix, full_decoded):
if a != b:
break
common_len += 1
return full_decoded[common_len:]
return ""completion 路径随后会把 chat 风格 logprob 对象转换为 completion 风格结构。这个函数创建四个平行列表,逐条复制 entry 字段,并在每个 entry 内构造 token → logprob 字典:
def _convert_chat_logprobs_to_completion_logprobs(
logprobs: ChatCompletionLogProbs,
) -> CompletionLogProbs:
if logprobs.content is None:
return CompletionLogProbs()
tokens: list[str] = []
token_logprobs: list[float | None] = []
top_logprobs_list: list[dict[str, float] | None] = []
text_offset: list[int] = []
offset = 0
for entry in logprobs.content:
text_offset.append(offset)
tokens.append(entry.token)
token_logprobs.append(entry.logprob)
top_logprobs_list.append(
{t.token: t.logprob for t in entry.top_logprobs}
if entry.top_logprobs
else None
)
offset += len(entry.token)
return CompletionLogProbs(
text_offset=text_offset,
token_logprobs=token_logprobs,
tokens=tokens,
top_logprobs=top_logprobs_list,
)因此,logprobs.content 的长度和每条 top_logprobs 的大小,分别决定外层 entry 循环和内层字典构造的规模。它们与 token_ids 主路径可以同时出现,导致同一请求既消耗 decode 成本,又消耗 logprob 转换成本。
这里还要区分“公告中的风险面”和“本次源码审计确认的 sink”。routed_experts 是同一 GenerateResponseChoice 上的相邻可控字段,公开资料提到它属于可控结构的一部分;但在当前修复前 derenderer 路径中,没有发现它像 token_ids 或 logprob 容器那样进入 decode、parser、循环转换或独立大小校验。因此本文只把它作为相邻可控字段提示,不把它写成已验证的独立资源 sink。
5.6 字符串、对象复制与响应序列化的二次放大
tokenizer.decode() 的输出不是一个临时值。plain chat 会把 decoded_text 放入 ChatMessage.content;completion 会把它放入 CompletionResponseChoice.text;parser 分支还会把完整文本交给 parser.parse(),再把解析结果组织成 reasoning、content 和 tool call 结构。
随后,服务层把这些 choice 对象放入 OpenAI 兼容响应模型。路由层又调用 result.model_dump(),并将结果交给 JSONResponse。因此,资源成本至少有三个阶段:
这解释了为什么该漏洞不能只按“decode 很慢”理解。大 token 列表会导致解码成本;大 choice / logprob 会导致对象图变大;大响应还会带来序列化、缓冲和网络写出成本。即使某个 tokenizer 对重复 token 解码较快,响应对象和 JSON 输出仍然可能随输入规模增长。
5.7 按端点拆解资源函数
把 chat 和 completion 分开看,资源放大结构会更清楚。chat derender 只有一个 generate_response,所以主要放大因子来自 choices、每个 choice 的 token 数、可选 parser 和 logprob:
W_chat ≈ Σ_j (
D(T_j)
+ Logprob(L_j, P_j)
+ Parser(S_j, T_j)
+ ResponseObject(S_j, L_j, P_j)
)
+ DumpAndSerialize(response)其中 D(T_j) 表示对 T_j 个 token IDs 的 decode 成本,S_j 表示解码后的字符串长度。Parser(S_j, T_j) 只在配置 parser 且请求包含 chat_request 时出现;plain 分支没有 parser,但仍有 decode 和响应构造。DumpAndSerialize(response) 表示路由层对完整响应做 model_dump() 并生成 JSON 响应的成本。
completion derender 多一层 generate_responses,所以同样的 choice 成本会被外层 response 数放大:
W_completion ≈ O(R)
+ Σ_i Σ_j (
D(T_ij)
+ Logprob(L_ij, P_ij)
+ CompletionLogprobConvert(L_ij, P_ij)
+ ResponseObject(S_ij, L_ij, P_ij)
)
+ DumpAndSerialize(response)这个形式化拆解有两个用处。第一,它说明漏洞不依赖某个单点最坏情况:即使 parser 未启用,decode、choice 对象和 JSON 输出仍然存在;即使 token 列表不夸张,logprob 容器也能形成额外遍历。第二,它说明防护必须覆盖所有乘法因子:R、C_i、T_ij、L_ij 和 P_ijk 缺一不可。
5.8 修复前后的复杂度上界
用符号表示可以更清楚地看到补丁改变了什么。设:
R = len(generate_responses)
C_i = len(generate_responses[i].choices)
T_ij = len(choice[i][j].token_ids)
L_ij = len(choice[i][j].logprobs.content)
P_ijk = len(logprobs.content[k].top_logprobs)
M = model_config.max_model_len
N = VLLM_MAX_N_SEQUENCES修复前,应用层没有给 R、C_i、T_ij、L_ij、P_ijk 设置业务上限。实际最大值会被 HTTP body、JSON 解析、内存和代理配置间接限制,但这些不是 derender 语义层面的稳定边界。资源成本可以粗略写成:
W_pre ≈ O(
Σ_i Σ_j T_ij
+ Σ_i Σ_j L_ij
+ Σ_i Σ_j Σ_k P_ijk
+ response_serialization_size
)修复后,关键变量被绑定到服务端配置:
R <= N
C_i <= N
T_ij <= M
L_ij <= M
P_ijk <= 20这使得单个字段不再能无限驱动对应 sink。不过 completion 路径的理论总量仍是组合上界:最宽松情况下,choice 数可以达到 N * N,每个 choice 又能携带接近 M 的 token/logprob 长度。也就是说,补丁把“无业务上界”变成“由服务端配置定义的有限上界”,但没有把整个 payload 压缩成单一的总 token 预算。这正是第七章会强调“补丁不是传输层替代品”的原因。
这个复杂度视角也能解释为什么只限制 token_ids 不够:即使 T_ij 有上限,如果 R 或 C_i 没有限制,总 decode 次数和响应对象数仍可被放大;如果 L_ij 和 P_ijk 没有限制,logprob 路径仍能绕开 token 主路径形成额外成本。
这些乘法因子会在补丁的运行时上限与回归测试中逐一得到约束,边界必须在 derenderer 之前建立。
5.9 容易误判的几个点
分析这类资源消耗漏洞时,最常见的问题不是找不到代码,而是把相邻事实误当作根因。对本案而言,可以明确排除或降级如下解释:
误判说法 | 为什么不准确 | 更准确的表述 |
“这是 tokenizer 漏洞” |
| 这是 derender 在调用 tokenizer 前缺少输入规模限制 |
“这是 parser 漏洞” | parser 分支会增加成本,但 plain chat 和 completion 路径也能到达 decode | parser 是可叠加成本,不是唯一根因 |
“有 API key 就安全” | API key 只证明调用方身份,不能限制单请求触发的循环、decode 和序列化规模 | 鉴权应与端点级配额、body 限制和字段上限配合 |
“Pydantic 已经校验,所以不是输入问题” | Pydantic 在旧 schema 中只表达类型和结构,没有表达最大长度 | 类型正确仍可能资源不安全 |
“空列表检查说明有边界意识” | completion 的空列表检查位于 derender 调用后,而且只处理空列表,不处理超大非空列表 | 资源边界必须在昂贵操作前执行,并覆盖上限 |
“只限制 | response、choice、logprob 和 prompt_logprobs 也会放大循环、对象和响应体 | 每一层可控基数都需要上限 |
真正的根因不是某个库函数很慢,也不是某个 token 会触发异常,而是跨信任边界传入的嵌套结果对象缺少资源预算。静态分析可进一步验证这条链路:请求字段如何到达 derenderer,choice.token_ids 如何到达 decode,以及 logprob 容器如何进入循环和转换。
六、证据链:如何用静态分析验证,而不依赖高负载攻击
6.1 源码定位
审计基于修复提交父节点 e040899 的源码快照。入口位于 vllm/entrypoints/scale_out/derender/api_router.py;嵌套请求模型位于 vllm/entrypoints/scale_out/token_in_token_out/protocol.py;服务层转交位于 vllm/entrypoints/scale_out/derender/serving.py;vllm/renderers/online_derenderer.py 则是 decode、parser、logprob 处理和响应构造的核心位置。路由注册和 handler 挂载还经过 factory 与 app.state,这是 FastAPI 中常见、但静态工具未必能自动串联的动态边界。
6.2 CodeQL 结果应如何解读
查询 | 直接证明的事实 | 结果 |
| 请求字段被传给两个 derender 方法 | 2 条 flow |
|
| 3 条 flow |
| 路由、服务层和 decode 的调用边 | 10 个调用点 |
| logprob 容器存在处理循环 | 2 个 loop |
本次静态分析使用 CodeQL CLI 2.26.4 与 codeql/python-all@7.2.4,在修复前快照上以 --language=python --build-mode=none 建立数据库。Python extractor 对可选 Torch、NumPy 依赖的未解析导入有提示,但目标 Python 文件、AST、调用表达式及局部/全局数据流均已抽取;这些提示不改变下文的查询范围。
为使证据不依赖仓库中的查询或 CSV 文件,以下直接内嵌五个实际查询的完整逻辑。前两个是语法定位,后两个为全局 taint/data-flow,最后一个定位循环体:
/** KeyFunctions.ql:列出 derender 攻击面上的函数。 */
import python
from Function f
where
f.getName() = "derender_chat_completion" or
f.getName() = "derender_completion" or
f.getName() = "derender_chat_response" or
f.getName() = "derender_completion_response" or
f.getName() = "derender_chat" or
f.getName() = "_resolve_logprobs" or
f.getName() = "_convert_chat_logprobs_to_completion_logprobs"
select f, f.getName(), f.getLocation().getFile().getRelativePath(), f.getLocation().getStartLine()/** DerenderCalls.ql:连接 route、service 与 derenderer 的目标调用边。 */
import python
from Call c, Attribute a
where
a = c.getFunc() and
c.getLocation().getFile().getRelativePath() in [
"vllm/entrypoints/scale_out/derender/api_router.py",
"vllm/entrypoints/scale_out/derender/serving.py",
"vllm/renderers/online_derenderer.py"
] and
a.getName() in [
"derender_chat_response", "derender_completion_response",
"derender_chat", "derender_completion", "decode",
"_resolve_logprobs", "_convert_chat_logprobs_to_completion_logprobs"
]
select c, a.getName(), c.getLocation().getFile().getRelativePath(), c.getLocation().getStartLine()/** ServingToDerenderer.ql:验证服务层请求字段流向 OnlineDerenderer 调用。 */
import python
import semmle.python.dataflow.new.DataFlow
import semmle.python.dataflow.new.TaintTracking
module Config implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
exists(Attribute a |
a.getName() in ["generate_response", "generate_responses"] and
a.getLocation().getFile().getRelativePath() = "vllm/entrypoints/scale_out/derender/serving.py" and
source.asExpr() = a
)
}
predicate isSink(DataFlow::Node sink) {
exists(Call c, Attribute a |
a = c.getFunc() and a.getName() in ["derender_chat", "derender_completion"] and
c.getLocation().getFile().getRelativePath() = "vllm/entrypoints/scale_out/derender/serving.py" and
sink.asExpr() = c.getArg(0)
)
}
}
module Flow = TaintTracking::Global<Config>;
from DataFlow::Node source, DataFlow::Node sink
where Flow::flow(source, sink)
select sink.asExpr(), source.asExpr().getLocation().getStartLine(), sink.asExpr().getLocation().getStartLine(), "Derender request field reaches OnlineDerenderer."/** TokenIdsToDecode.ql:验证 choice.token_ids 直接流向 tokenizer.decode 的首个参数。 */
import python
import semmle.python.dataflow.new.DataFlow
import semmle.python.dataflow.new.TaintTracking
module Config implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
exists(Attribute a |
a.getName() = "token_ids" and
a.getLocation().getFile().getRelativePath() = "vllm/renderers/online_derenderer.py" and
source.asExpr() = a
)
}
predicate isSink(DataFlow::Node sink) {
exists(Call c, Attribute a |
a = c.getFunc() and a.getName() = "decode" and
sink.asExpr() = c.getArg(0) and
c.getLocation().getFile().getRelativePath() = "vllm/renderers/online_derenderer.py"
)
}
}
module Flow = TaintTracking::Global<Config>;
from DataFlow::Node source, DataFlow::Node sink
where Flow::flow(source, sink)
select sink.asExpr(), source.asExpr().getLocation().getStartLine(), sink.asExpr().getLocation().getStartLine(), "Caller-controlled token_ids reach tokenizer.decode()."/** LogprobsLoops.ql:定位处理可控 logprob 容器的循环体。 */
import python
from For f, Attribute a
where
f.getBody().contains(a) and
a.getName() in ["content", "top_logprobs"] and
f.getLocation().getFile().getRelativePath() = "vllm/renderers/online_derenderer.py"
select f, a.getName(), f.getLocation().getStartLine()上述查询在该快照的实际 CSV 输出如下;这里保留原始列值而不是只保留其口头概述:
# key-functions.csv
"f","col1","col2","col3"
"Function derender_chat_completion","derender_chat_completion","vllm/entrypoints/scale_out/derender/api_router.py",39
"Function derender_completion","derender_completion","vllm/entrypoints/scale_out/derender/api_router.py",64
"Function derender_chat","derender_chat","vllm/renderers/online_derenderer.py",75
"Function derender_completion","derender_completion","vllm/renderers/online_derenderer.py",163
"Function _resolve_logprobs","_resolve_logprobs","vllm/renderers/online_derenderer.py",259
"Function _convert_chat_logprobs_to_completion_logprobs","_convert_chat_logprobs_to_completion_logprobs","vllm/renderers/online_derenderer.py",304
"Function derender_chat_response","derender_chat_response","vllm/entrypoints/scale_out/derender/serving.py",54
"Function derender_completion_response","derender_completion_response","vllm/entrypoints/scale_out/derender/serving.py",106
# derender-calls.csv
"c","col1","col2","col3"
"Attribute()","derender_chat_response","vllm/entrypoints/scale_out/derender/api_router.py",46
"Attribute()","derender_completion_response","vllm/entrypoints/scale_out/derender/api_router.py",69
"Attribute()","decode","vllm/renderers/online_derenderer.py",97
"Attribute()","decode","vllm/renderers/online_derenderer.py",147
"Attribute()","decode","vllm/renderers/online_derenderer.py",187
"Attribute()","decode","vllm/renderers/online_derenderer.py",232
"Attribute()","decode","vllm/renderers/online_derenderer.py",239
"Attribute()","decode","vllm/renderers/online_derenderer.py",244
"Attribute()","derender_chat","vllm/entrypoints/scale_out/derender/serving.py",72
"Attribute()","derender_completion","vllm/entrypoints/scale_out/derender/serving.py",124
# serving-to-derenderer.csv
"col0","col1","col2","col3"
"Attribute",73,73,"Derender request field reaches OnlineDerenderer."
"Attribute",125,125,"Derender request field reaches OnlineDerenderer."
# tokenids-to-decode.csv
"col0","col1","col2","col3"
"Attribute",98,98,"Caller-controlled token_ids reach tokenizer.decode()."
"Attribute",148,148,"Caller-controlled token_ids reach tokenizer.decode()."
"Attribute",188,188,"Caller-controlled token_ids reach tokenizer.decode()."
# logprobs-loops.csv
"f","col1","col2"
"For","top_logprobs",269
"For","top_logprobs",318最关键的原始结果可以直接翻译成如下证据:
结果文件 | 行号 | 证明点 |
| 73 |
|
| 125 |
|
| 98 | chat parser 分支的 |
| 148 | chat plain 分支的 |
| 188 | completion 分支的 |
| 46、69 | 两条 route handler 调用 service 层 |
| 97、147、187、232、239、244 | derenderer 中相关 |
| 269、318 | logprob 相关循环存在 |
这些行号对应分析数据库中的修复前快照 e040899a006ffd07c51151b32d29649f30cb21e9。如果读者在不同版本上复跑查询,行号可能变化,但应该保留同类关系:请求字段进入 derenderer,token_ids 进入 decode,logprob 容器进入循环。
6.3 从局部 flow 到完整链条
CodeQL 的 Python 模型没有将 FastAPI 的 JSON body 绑定、Pydantic 动态字段和 app.state 动态属性完整合并为一条端到端污点路径。这里的完整证据链因此由四段组成:路由的请求类型注解;Pydantic 字段定义;服务层的显式属性传参;以及 CodeQL 返回的服务层到 derenderer、token_ids 到 decode 的局部 flow。每一段都可以在相邻源码中独立核对。
修复前还没有发现对 token_ids 作 max_model_len 比较、对 choices / generate_responses 作上限判断,或在进入 derenderer 前截断这些容器的 barrier。if not choice.token_ids 只拒绝空值或 None,不能限制很长的非空列表。这个负面结论很重要:它来自特定快照的代码审阅,不等价于对所有版本或所有部署配置的笼统断言。
6.4 查询设计:把框架边界拆成可证明的小段
对 FastAPI、Pydantic 和动态 app.state 混合的 Python 服务,强行追求“一条查询证明端到端污点路径”容易得到脆弱结论。更稳的方式是把漏洞假设拆成多个可人工复核的小命题:
小命题 | 验证方式 | 结论边界 |
HTTP 路由接收 derender 请求模型 | 读 | 证明入口存在,不证明字段已到 sink |
请求模型包含可控嵌套列表 | 读 | 证明外部 JSON 可表达结构,不证明处理成本 |
服务层把请求字段交给 derenderer |
| 证明跨过 service boundary 的显式传参 |
|
| 证明主要资源 sink 可达 |
logprob 容器进入循环 |
| 证明叠加型 sink 存在 |
修复前缺少规模 barrier | 代码审计和补丁对照 | 证明特定快照缺失上限,不替代运行时配置评估 |
这个设计把证据分为 positive evidence 和 negative evidence。positive evidence 证明字段确实能走到后处理;negative evidence 证明在该快照中,没有在 sink 前看到足以限制规模的 barrier。两者合在一起,才支撑“格式合法的可控结构可驱动资源消耗”这个结论。
静态工具负责定位可达关系和循环结构,人工审计负责解释框架动态、校验语义和补丁前后的安全不变量。CodeQL 不能替代源码阅读,二者需要相互校准。
实际复跑时,可以在 codeql/ 目录下使用同一数据库和查询包:
codeql query run queries/TokenIdsToDecode.ql \
--database=database-vllm-e040899-final \
--output=results/tokenids-to-decode.bqrs
codeql bqrs decode results/tokenids-to-decode.bqrs \
--format=csv \
--output=results/tokenids-to-decode.csv这类查询的价值不是“代替 PoC”,而是在不对任何在线服务施压的情况下,把 source、barrier 和 sink 的位置固定下来。对于资源消耗漏洞,这通常比盲目寻找崩溃阈值更能支持可复核、低风险的分析。
6.5 三个资源放大点
本章的查询分别固定了三类可验证关系:choice.token_ids 到达 decode;启用 chat parser 时,完整解码文本继续进入 parser;以及 logprob 的 content 与 top_logprobs 会被循环处理。第五章已经说明这些路径如何随 response、choice、token 和 logprob 基数放大;这里的 CodeQL 结果只证明可达性与循环存在,不推导具体机器的 OOM 阈值。下面转而检查补丁如何在这些 sink 之前恢复边界。
七、补丁分析:把检查移到资源 sink 之前
7.1 上游修复信息
上游通过 提交 8e61b646 修复此问题,关联 PR #47260,提交信息为 fix(security): add resource bounds validation to derender endpoints。首个包含该修复的发布版本是 vLLM 0.26.0。
这个提交改动了三类文件:
文件 | 改动作用 |
| 新增 |
| 为 |
| 新增资源边界回归测试 |
其中,与 CVE 资源耗尽根因直接对应的是服务层的 _validate_derender_bounds():它把 caller-supplied GenerateResponse 的多层结构重新绑定到服务端已有资源边界,然后才允许进入 tokenizer.decode()、parser 和 logprob 后处理。
7.2 补丁的关键 diff
下列是新增边界检查的完整函数体。为便于阅读,这里展示的是补丁落地后的函数,而非省略错误信息的伪代码:
def _validate_derender_bounds(
self,
generate_responses: list[GenerateResponse],
) -> ErrorResponse | None:
"""Reject derender payloads that exceed resource bounds.
Runs before any tokenizer.decode() or parser invocation to prevent
CPU/memory exhaustion from oversized caller-supplied token structures.
"""
max_n = envs.VLLM_MAX_N_SEQUENCES
max_model_len = self.model_config.max_model_len
if len(generate_responses) > max_n:
return self.create_error_response(
f"generate_responses count ({len(generate_responses)}) "
f"exceeds server maximum ({max_n}). "
f"Set VLLM_MAX_N_SEQUENCES to increase this limit."
)
for gen in generate_responses:
if len(gen.choices) > max_n:
return self.create_error_response(
f"choices count ({len(gen.choices)}) in response "
f"'{gen.request_id}' exceeds server maximum ({max_n})."
)
for choice in gen.choices:
if choice.token_ids and len(choice.token_ids) > max_model_len:
return self.create_error_response(
f"token_ids length ({len(choice.token_ids)}) in "
f"choice {choice.index} exceeds "
f"max_model_len ({max_model_len})."
)
if choice.logprobs and choice.logprobs.content:
if len(choice.logprobs.content) > max_model_len:
return self.create_error_response(
f"logprobs.content length "
f"({len(choice.logprobs.content)}) in "
f"choice {choice.index} exceeds "
f"max_model_len ({max_model_len})."
)
for entry in choice.logprobs.content:
if entry.top_logprobs and len(entry.top_logprobs) > 20:
return self.create_error_response(
f"top_logprobs count "
f"({len(entry.top_logprobs)}) in "
f"choice {choice.index} exceeds maximum (20)."
)
if gen.prompt_logprobs and len(gen.prompt_logprobs) > max_model_len:
return self.create_error_response(
f"prompt_logprobs length ({len(gen.prompt_logprobs)}) "
f"in response '{gen.request_id}' exceeds "
f"max_model_len ({max_model_len})."
)
return Nonechat 路径在 _check_model() 之后、online_derenderer.derender_chat() 之前调用该函数。下面的连续代码包含调用后的异常处理,因此可以直接看出任何 bounds error 都在 derenderer 之前 return:
async def derender_chat_response(
self, request: DerenderChatRequest
) -> ChatCompletionResponse | ErrorResponse:
error_check_ret = await self._check_model(request)
if error_check_ret is not None:
return error_check_ret
bounds_error = self._validate_derender_bounds([request.generate_response])
if bounds_error is not None:
return bounds_error
try:
choices = await self.online_derenderer.derender_chat(
request.generate_response, request.chat_request
)
except ValueError as exc:
return self.create_error_response(str(exc))completion 路径除了增加同样的边界校验,还把空 generate_responses 的检查从 derenderer 调用之后移到了之前。调用与返回值解包的连续上下文如下:
async def derender_completion_response(
self, request: DerenderCompletionRequest
) -> CompletionResponse | ErrorResponse:
error_check_ret = await self._check_model(request)
if error_check_ret is not None:
return error_check_ret
if not request.generate_responses:
return self.create_error_response("generate_responses must not be empty")
bounds_error = self._validate_derender_bounds(request.generate_responses)
if bounds_error is not None:
return bounds_error
choices, total_prompt_tokens, total_completion_tokens = (
await self.online_derenderer.derender_completion(
request.generate_responses, request.prompt_tokens
)
)这个位置选择很重要:补丁没有改变 derenderer 内部的 decode、parser 或响应构造逻辑,而是在服务层把不满足资源边界的输入提前拒绝。因此,合法大小的 derender 功能保留,超限结构不再到达昂贵 sink。
7.3 为什么校验放在 service 层
直觉上,长度限制似乎也可以写进 Pydantic schema;但这个漏洞的关键边界并不是固定常量,而是依赖运行时配置:
model_config.max_model_len来自当前服务加载的模型配置,用来约束单个 choice 的token_ids、logprobs.content和 response 的prompt_logprobs。VLLM_MAX_N_SEQUENCES是服务端环境变量,用来约束 completion 的generate_responses数量和每个 response 内的choices数量。top_logprobs使用 OpenAI 兼容接口里的最大值 20,限制每个 logprob entry 内部的嵌套展开。
这些值需要在 ServingDerender 拿到模型配置和服务端环境之后才能准确判断。也正因为如此,补丁选择在 _check_model() 之后执行:先确认请求面向的是已知模型,再使用该模型的 max_model_len 做语义化边界校验。
不过这也解释了补丁的剩余边界:HTTP body 已经被接收,JSON 已经被解析,Pydantic 对象也已经被构造。补丁阻断的是更下游、更昂贵的后处理成本,而不是替代网关层的 body size、JSON 深度或速率限制。
7.4 补丁恢复的安全不变量
修复前,GenerateResponse 的多个嵌套维度都可以由调用方控制;补丁在进入对应 sink 前为每一层恢复了下表所列不变量。
安全不变量 | 修复前 | 修复后的上限 | 覆盖的 sink / 下游成本 |
completion 的 response 数受服务端序列上限约束 |
|
|
|
单个 response 的 choice 数受服务端序列上限约束 |
|
| chat / completion 的 choice 循环、响应对象、usage 聚合 |
单个 choice 的 token 数不超过模型上下文上限 |
|
|
|
logprob content 不超过模型上下文上限 |
|
|
|
单个 token 的 top candidates 有固定上限 |
|
| 嵌套循环、字典构造和响应体 |
prompt 侧 logprob 不会无限携带 |
|
| 响应模型与 JSON 序列化 |
这种修复不是简单地“加一个最大请求数”,而是按数据结构逐层收口:任一可控维度被放大,都会在到达对应处理逻辑前返回 400。
7.5 负 token ID 校验的角色
同一提交还在 GenerateResponseChoice 上新增了 token_ids 的 field validator:
+@field_validator("token_ids")
+@classmethod
+def validate_token_ids(cls, v: list[int] | None) -> list[int] | None:
+ if v is not None and any(t < 0 for t in v):
+ raise ValueError("token_ids must not contain negative values")
+ return v这属于 schema 一致性增强:token ID 应该是非负整数,负值不应被传给 tokenizer 或后续处理链。它有安全价值,但不要把它误读为 CVE 的主要修复。CVE-2026-71486 的核心问题是“数量和长度没有资源上限”;负 token ID 校验解决的是非法值,而 _validate_derender_bounds() 解决的是合法但过大的结构。
7.6 回归测试证明了什么
上游测试并不只验证 happy path,而是补上了一组负向边界用例:
测试场景 | 期望结果 | 证明的不变量 |
正常小 payload | HTTP 200 | 合法 derender 工作流未被补丁破坏 |
chat oversized | HTTP 400,错误信息包含 | 超长 token 列表在 decode 前被拒绝 |
chat choices 数过多 | HTTP 400,错误信息包含 | choice 维度不能任意放大 |
completion | HTTP 400,错误信息包含 | completion 外层 response 维度受服务端上限约束 |
negative | HTTP 400 | 非法 token ID 在协议层被拒绝 |
oversized | HTTP 400 | logprob 内容长度不会越过模型上下文边界 |
oversized | HTTP 400 | 单个 logprob entry 的候选项数量不超过 20 |
completion oversized | HTTP 400 | chat 与 completion 两条 derender 路径都覆盖到 token 长度边界 |
这些测试直接验证的是超限输入会被拒绝、正常小输入仍可完成。结合本节 7.2 的控制流 diff(校验调用位于 derender_chat() / derender_completion() 之前),才能得出“拒绝发生在 decode、parser 和 logprob 后处理之前”的结论;测试本身并未插桩 tokenizer 来单独测量是否发生过 decode。对拒绝服务类漏洞来说,这种“边界测试 + 控制流审计”的组合比寻找某个具体 OOM 临界点更稳。
7.7 补丁没有覆盖的边界
补丁把风险从“调用方可任意驱动 decode / parser / logprob / 响应构造”收敛为“后处理工作量受模型和服务端配置约束”。但它不是完整的传输层防线:
- 校验发生在 FastAPI 接收 body、JSON 解析和 Pydantic 构造之后,因此大 body、深层 JSON 或高并发请求仍需要由反向代理、网关或服务网格限制。
- completion 的理论最大后处理工作量仍由多个维度相乘得到:
generate_responses数量、每个 response 的choices数量、每个 choice 的token_ids/logprobs.content上限。补丁没有额外引入一个“整个 payload 总 token 数”字段。 routed_experts在协议中仍是字符串字段;公开公告提到它属于可控结构的一部分,但该提交没有为它增加独立大小校验。分析时应把“公告确认的风险面”和“本次 diff 实际新增的边界”分开表述。
因此,升级到 0.26.0 是必要动作,但运行侧仍应保留分层控制:网关限制 body、JSON 深度、并发和速率;应用层保留语义化字段上限;平台侧按身份、端点和内部调用链隔离 derender 能力。
7.8 可迁移的审计经验
这个补丁给辅助 API 的审计留下三条经验。
第一,类型校验不等于资源校验。list[int] 能保证字段形状,却不能保证列表规模合理;只要后续存在 decode、解析、复制、序列化等线性或嵌套成本,就必须显式绑定上限。
第二,边界检查必须早于第一个昂贵 sink。修复前 completion 的空列表检查甚至位于 derenderer 调用之后,说明“有检查”还不够,检查位置本身也是安全属性。
第三,辅助接口也要继承主路径的资源不变量。derender 虽然不是推理入口,但它处理的是生成结果结构;因此它同样需要模型上下文长度、序列数量、logprob 数量这些主路径已经具备的预算概念。下一章的安全复现正是围绕这个不变量展开:修复后,超限数据不会再到达 decode。
八、安全复现:从零搭建到有界差分验证
8.1 复现目标与边界
本章直接给出安全差分实验室的运行材料和流程:从空白 .env 环境开始,构建并启动两个隔离容器服务,发送有界 derender 请求,观察 vulnerable 与 patched 的响应差异,最后通过 metrics 证明 decode sink 是否被触达。
这个实验室不是完整 vLLM 推理环境。它以两个最小 HTTP 服务模拟修复前后控制流:vulnerable 对资源规模不执行边界校验,会直接进入伪 decode;patched 则在 decode 前进行同类边界校验。验证请求只包含 max_model_len + 1 个 token ID;当前配置为 65 个 ID 对比上限 64。它的目的不是制造真实 OOM,而是证明补丁的拦截时机。
所有流量只走本机回环地址或 Compose 内部网络。不要将实验室 payload、并发参数或任何放大测试迁移到生产、第三方或未获授权的 vLLM 服务。
下面的终端截图展示本次验证中的关键输出。HTTP 触发和 metrics 读取使用 Compose 网络或服务容器内部命令完成。
8.2 从零准备环境
外部前提只有一个:宿主机已经安装并启动 Docker Engine / Docker Desktop,且 Docker Compose v2 可用。实验室不需要 GPU、模型权重、数据库或宿主机 Python 依赖;容器内只使用 CPython 标准库。
从仓库根目录进入 reproduction/,复制 .env.example 为 .env。.env 中的 LAB_API_KEY 只用于本机实验,应替换为随机值;文中的命令与截图仅使用占位值,不包含真实凭据。
实验室使用的 .env 模板与固定边界如下。前者是本机实验密钥和 loopback 端口的唯一可替换配置;后者是最小行为模型实际读取的限制值,因此 65-ID 样本恰好比 token 上限大 1。
# Copy this file to .env before running Docker Compose.
# This is a lab-only shared secret, not a production credential.
LAB_API_KEY=replace-with-a-long-random-lab-secret
LAB_MODEL=tiny-derender-model
VULNERABLE_PORT=18080
PATCHED_PORT=18081{
"max_model_len": 64,
"max_n_sequences": 8,
"max_top_logprobs": 20,
"max_request_bytes": 262144
}reproduction/ 是复现实验室的工作目录。Compose 文件、Dockerfile、限制配置和验证器都在这里;如果在仓库根目录直接运行 docker compose ...,会得到 no configuration file provided。
8.3 构建并启动差分服务
启动命令会构建固定 digest 的 Python 基础镜像,并拉起两个服务:
vulnerable:发布到127.0.0.1:18080,模拟修复前路径;patched:发布到127.0.0.1:18081,模拟修复后路径。
这一隔离并非口头约定。实际 Compose 定义让两个服务复用同一只含标准库的镜像、只向 loopback 发布端口,并设置只读根文件系统、临时 /tmp、无 Linux capability、no-new-privileges、256m 内存和 0.50 CPU 限额;验证器只通过内部 DNS 访问服务。核心编排如下:
services:
vulnerable:
image: cve-2026-71486-lab:local
environment:
LAB_MODE: vulnerable
LAB_API_KEY: ${LAB_API_KEY:-lab-only-change-me}
LAB_MODEL: ${LAB_MODEL:-tiny-derender-model}
LAB_LIMITS_FILE: /opt/cve-2026-71486/config/limits.json
ports: ["127.0.0.1:${VULNERABLE_PORT:-18080}:8000"]
networks: [cve71486]
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 256m
cpus: 0.50
patched:
image: cve-2026-71486-lab:local
environment:
LAB_MODE: patched
LAB_API_KEY: ${LAB_API_KEY:-lab-only-change-me}
LAB_MODEL: ${LAB_MODEL:-tiny-derender-model}
LAB_LIMITS_FILE: /opt/cve-2026-71486/config/limits.json
ports: ["127.0.0.1:${PATCHED_PORT:-18081}:8000"]
networks: [cve71486]
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 256m
cpus: 0.50
verify:
image: cve-2026-71486-lab:local
profiles: [verification]
entrypoint: ["python", "/opt/cve-2026-71486/scripts/verify.py"]
environment:
LAB_API_KEY: ${LAB_API_KEY:-lab-only-change-me}
VULNERABLE_URL: http://vulnerable:8000
PATCHED_URL: http://patched:8000
networks: [cve71486]
networks:
cve71486:
name: cve-2026-71486-isolated启动后检查服务状态。两个服务都应进入 healthy,并且端口只绑定到宿主机 loopback。
如果首次构建较慢,通常是 Docker 正在拉取固定 digest 的 python:3.12.8-slim-bookworm 基础镜像。如果端口冲突,可以在 .env 中调整 VULNERABLE_PORT 或 PATCHED_PORT 后重新启动。
8.4 自动验证两条路由的有界差分
推荐先运行仓库内置验证器。该验证器在 verify 容器里执行,通过 Compose 内部 DNS 分别访问 http://vulnerable:8000 和 http://patched:8000,不会依赖宿主机 curl 或 Python 环境。
验证器覆盖两个 derender 路由:
/v1/chat/completions/derender/v1/completions/derender
它向每个路由发送同一个有界样本:65 = max_model_len (64) + 1 个 token ID。逻辑上等价于下面的请求结构:
{
"model": "tiny-derender-model",
"generate_response": {
"request_id": "bounded-cve-check",
"choices": [
{
"index": 0,
"token_ids": [42, 42, "... 共 65 项"]
}
]
}
}这里的 ... 只是文档缩写。验证器实际以 [42] * 65 构造 payload,并对两个端点依次断言旧路径为 200、修复路径为 400,最后检查累计 metrics。其关键逻辑直接内嵌如下:
def check_endpoint(path: str) -> None:
vulnerable, patched = os.environ["VULNERABLE_URL"], os.environ["PATCHED_URL"]
status, body = call(vulnerable, path, payload(path, [42] * 65))
assert status == 200, ("vulnerable did not accept bounded oversized structure", status, body)
assert len(body["choices"][0]["text"].split()) == 65, body
status, body = call(patched, path, payload(path, [42] * 65))
assert status == 400 and "max_model_len" in body["error"]["message"], (status, body)
def main() -> None:
for endpoint in ("/v1/chat/completions/derender", "/v1/completions/derender"):
check_endpoint(endpoint)
old = metrics(os.environ["VULNERABLE_URL"])
fixed = metrics(os.environ["PATCHED_URL"])
assert old["decoded_token_count"] >= 130, old
assert fixed["decoded_token_count"] == 0, fixed
print("PASS: vulnerable mode decoded caller-controlled over-limit token_ids; patched mode rejected both routes before decode.")具体的响应与 metrics 判定见 8.6。
8.5 手工触发并观察响应
自动验证通过后,可以再用一个容器内 Python 探针手工请求 chat derender 路由。这里仍然只发送 65 个 token ID,目的是直接看到两端响应体的差异,而不是增加负载。
输出中的关键信号是:
vulnerable HTTP 200:旧路径接受超出max_model_len的 caller-supplied token 列表,并进入伪 decode;patched HTTP 400:新路径在 decode 前拒绝,错误信息明确包含token_ids length (65) exceeds max_model_len (64)。
服务日志也能看到同一次自动验证的差分结果:同样的两个 derender endpoint,在 vulnerable 服务上是 200,在 patched 服务上是 400。
8.6 用 metrics 证明 sink 是否触达
最后读取实验室专用 metrics。该端点只属于本仓库的最小行为模型,不是 vLLM 的真实 API;它用于把“是否进入 decode”变成可观察、可回归的数值。
在刚启动、尚未收到其他实验请求的服务中,自动验证完成后 vulnerable.decoded_token_count 为 130,因为两个路由各 decode 了 65 个 ID;patched.decoded_token_count 为 0。该指标是进程内累计值:重复运行验证或先做手工请求时,vulnerable 的值会高于 130。
满足下面三项即可判定复现成功:
vulnerable对两个 derender 路由的 65-ID 请求均返回 HTTP 200;patched对同样请求均返回 HTTP 400,且错误包含max_model_len;verify输出PASS,且 metrics 显示 vulnerable 已发生 decode、patched 的 decode 计数仍为 0。
8.7 观测而非压测:如何证明安全不变量
这个实验室刻意避免寻找 OOM 临界点。当前 scripts/verify.py 实际执行的是下表中的 token_ids = max_model_len + 1 一行,并通过 metrics 观测 sink 是否被触达。其余行是与上游补丁对齐的待实现扩展矩阵;当前验证器尚未包含相应 payload 和断言,因此不构成已覆盖的实验结果。
输入类型 | vulnerable 行为 | patched 行为 | 证明点 |
正常小输入 | 200,decode 计数增加 | 200,decode 计数增加 | 合法功能保留 |
| 200,decode 计数增加 | 400,decode 计数不变 | token 长度 barrier 在 decode 前生效 |
choices 略超服务端上限(待实现) | 旧版真实 vLLM 会进入 choice 处理路径 | 预期为 400 | choice 数量 barrier 生效 |
| 旧版真实 vLLM 会进入 completion 外层处理路径 | 预期为 400 | response 数量 barrier 生效 |
logprob content / top candidates 略超上限(待实现) | 旧版真实 vLLM 会进入遍历或转换路径 | 预期为 400 | logprob 嵌套 barrier 生效 |
安全复现的目标是证明控制流顺序,而不是制造资源枯竭;8.9 说明这些结果不能外推为真实服务的 OOM 阈值。
8.8 清理环境
复现完成后删除容器和隔离网络。实验室没有命名卷或数据库,这一步不会删除本地构建镜像。
如果需要完全重置,再次从 docker compose up -d --build 开始即可。若要连同本地镜像删除,可在确认没有其他实验依赖后执行 docker image rm cve-2026-71486-lab:local。
8.9 如何解释结果
“vulnerable 返回 200”证明的是简化行为模型中,超限而格式合法的 caller-supplied token 列表确实越过了 decode sink;“patched 返回 400 且 decode 为 0”证明校验位于 sink 之前。它并不测量真实 vLLM 的吞吐、RSS、GPU 利用率或崩溃点,也不证明某一生产实例必然被同样大小的输入压垮。
这类测试特别适合纳入升级后的回归:保留一个略高于已配置上限的固定、小规模负向样本,并在监控中确认被拒绝请求的 4xx、decode 计数和正常业务基线。以最小化、确定性的测试完成补丁验证后,下一章把结论落到运行中服务的检测与缓解动作。
8.10 常见失败与排查
现象 | 处理 |
| 当前目录不对。进入 |
| 先运行 |
返回 401 |
|
端口被占用 | 修改 |
无法拉取基础镜像 | 检查 Docker 是否运行,以及网络是否允许访问 Docker Hub;离线环境可先在联网机器构建并用 |
九、部署侧检测与缓解清单
9.1 检测信号
此类问题没有固定的恶意 token、IP 或文件哈希,检测应围绕规模、频率与资源消耗模式。至少记录并按端点、调用身份或 API key 聚合以下信号:
POST /v1/*/derender的请求数、请求延迟、状态码、Content-Length与响应字节数;- API 前端或后处理 worker 的 CPU、RSS、事件循环 / worker 队列长度与超时;
- 每个身份的 derender 请求速率、并发数、response / choice 数,以及在可安全采集时的 token-ID 总数;
- 反向代理缓冲、访问日志量和出口带宽的异常增长。
单一大请求不必然就是攻击,单一 CPU 峰值也不必然来自 derender。把路由维度、身份维度与资源曲线关联,才能区分正常的内部工作流和需要处置的异常模式。
9.2 缓解优先级
- 修复版本。 将实际运行镜像、依赖锁文件和实例升级到 vLLM
>= 0.26.0,而不是只更新构建文件。 - 缩小暴露面。 没有业务需求时拒绝 derender 路由;有需求时仅允许可信内部 worker 或明确的服务身份访问。
- 多层限额。 网关限制请求 body、JSON 深度、并发和每 key 速率;应用层保留字段长度 / 数量校验;worker 设置可观测的资源隔离与超时。
- 部署后验证。 在自有隔离环境执行有界负向测试,确认超限结构在 decode 前返回 400,再回归正常业务。
9.3 处置与验收矩阵
场景 | 首要确认项 | 优先处置 | 验收证据 |
多租户对外服务 | derender 是否可达,是否与生成 API 共用 key / 配额 | 升级;默认拒绝该路由;按端点与 key 设置独立限流 | 路由策略、版本清单、超限请求 400 与租户指标 |
内部 scale-out | derender 是否仅限 render worker 调用 | 网络策略 / 服务身份白名单;限制 body 与并发 | 安全组或服务网格规则、内部负向测试记录 |
凭据泄露排查 | 低权限 key 是否可以访问辅助路由 | 收紧权限、轮换泄露 key、缩短有效期 | 权限矩阵、审计日志、旧 key 失效证据 |
已出现异常资源消耗 | 是否集中在 derender,是否关联单个 key 或异常 body | 临时封禁路由 / 凭据并限流,保留证据后升级 | 请求日志、CPU / RSS / 响应大小曲线与恢复后基线 |
不要把任一控制当作万能补丁。代理限制降低输入进入进程的机会,上游补丁阻止超限结构进入后处理,身份边界减少可调用主体,监控则缩短发现和响应时间。它们覆盖的是不同阶段。
9.4 按团队拆解落地动作
不同团队关注的不是同一个控制点。把缓解拆到职责边界,能减少“已经升级,所以不用管路由”和“已经限流,所以不用升级”这类误判。
团队 | 需要回答的问题 | 落地动作 |
平台 / 推理服务团队 | 运行中版本是多少,derender 是否与主生成流量共享 worker | 升级到 |
网关 / SRE 团队 | 两个 derender 路由是否发布,是否有独立限制 | 默认拒绝不需要的路由;设置 body、JSON 深度、并发、速率和响应大小限制 |
安全团队 | 哪些身份能访问辅助路由,泄露 key 的影响面多大 | 审计 key / 服务身份权限;按 path 检索历史访问;轮换和最小化凭据 |
应用团队 | 业务是否真实依赖 derender,升级后正常流程是否受影响 | 确认依赖链;保留小规模负向回归样本;验证正常生成和后处理流程 |
可观测性团队 | 能否把 derender 异常与资源曲线关联 | 增加 path、key、status、body/response 大小、decode 时长和错误原因维度 |
这张表也能反向帮助定责:应用补丁解决的是后处理前边界,网关解决的是进入进程前边界,身份策略解决的是谁能触发入口,观测性解决的是多快发现异常。
9.5 变更后的最小验收
升级完成后,检查运行中版本而非仅 CI 构建日志;向网关确认两个路由的最终生效规则;以无害的、略超语义上限的负向样本验证 HTTP 400 与零 decode;最后观察正常生成请求的延迟和错误基线。这样既能证明修复到位,也不会把生产环境当作压力实验室。
十、结语:把“内部假设”转换为可执行的边界
CVE-2026-71486 的价值不在于一个罕见的 token 技巧,而在于它揭示了资源安全中容易被遗漏的边界:系统把“由服务端生成、已经受预算约束”的对象,作为客户端可回传的 HTTP 结构来处理,却没有重新施加同等约束。
归根结底,有三点值得保留。
- 资源消耗漏洞不只取决于“有没有输入校验”,还取决于校验是否位于昂贵 sink 之前,以及是否覆盖嵌套数据的每个可放大维度。
- scale-out、运维和辅助 API 都是生产攻击面;“调用方可信”应被改写为可执行的服务身份、路由白名单、网络策略和配额。
- 静态数据流证据配合低负载差分验证,能够在不向真实服务施压的前提下,清晰验证根因与补丁不变量。
现在最实用的行动项是:盘点两个 derender 路由的暴露情况与调用主体,升级到 0.26.0 或更高版本,补齐网关和最小权限控制,并将“超限输入必须在 decode 前被拒绝”的有界负向测试加入发布后回归。这样,原本依赖内部约定的安全假设才真正成为可验证、可观测、可持续维护的资源边界。
10.1 可复用的辅助 API 审计清单
这类问题不只存在于 derender。任何 render / derender、serialize / deserialize、batch postprocess、trace replay、logprob conversion、tool-call parser 或离线评测接口,只要接收“看起来像内部结果”的对象,都值得按下面的问题审一遍。
审计问题 | 在本案中的对应点 |
输入对象的名字是否暗示“内部可信”,但实际可由 HTTP body 提供 |
|
对象是否包含列表、字典、token IDs、logprobs、base64 或可嵌套结构 |
|
schema 是否只有类型声明,没有 | 修复前 |
第一次昂贵操作发生在哪里 |
|
上限检查是否发生在第一次昂贵操作之前 | 修复前否;修复后 |
正常主路径的预算是否被辅助路径复用或重新验证 | 生成路径预算不会自动约束 derender 请求体 |
响应是否会保留、复制或原样返回可控嵌套字段 |
|
测试是否覆盖超限负向样例 | 修复提交新增 oversized token IDs、choices、responses、logprobs 测试 |
这份清单的核心不是“找一个危险函数”,而是找一条缺少预算继承的路径。很多资源消耗问题并不需要复杂 payload;只要可控容器能在校验前抵达昂贵循环,漏洞就已经具备基本形态。
10.2 一句话带走
如果只能保留一句话,CVE-2026-71486 的根因可以概括为:
derender API 接受客户端提交的生成结果对象,却在 decode、parser、logprob 转换和响应构造之前,没有把这些对象的 response 数、choice 数、token 数和 logprob 嵌套规模重新绑定到服务端资源预算。
本文所用上游事实可交叉核对 GitHub Advisory、NVD 条目、修复提交 与 v0.26.0 发布版。