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 < 0.26.0,且 derender 路由已启用并可达

入口

POST /v1/chat/completions/derender、POST /v1/completions/derender

攻击前提

攻击者能够访问接口;官方 CVSS 以低权限 API 访问(PR:L)为假设

影响

CPU、内存、响应体与带宽竞争,可降低同一服务的可用性

修复

升级至 vLLM 0.26.0 或更高版本

紧急缓解

无需求时在网关拒绝 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 接收回传 GenerateResponse,修复前未将其重新绑定到模型上下文和服务端序列预算

主生成 API 即使限制了 max_tokens,辅助 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 日志、测试脚本、浏览器配置、服务配置文件

泄露凭据未必能读数据,却可能触发可用性影响

权限过宽的服务身份

被授予 /v1/* 或网关通配路由的调用方

主生成端点受限时,辅助端点可能仍被同一身份访问

这也是该 CVE 的现实价值:低权限并不等于低影响。对于共享 API 前端,调用者身份只回答“谁能发请求”,不能回答“单次请求最多能让后端做多少 decode、对象复制和序列化”。

3.2 前置条件矩阵

风险评估可以从下面这张矩阵开始。任一条件不成立,攻击链就会被削弱或中断;条件越多同时成立,优先级越高。

条件

如何确认

风险含义

vLLM 版本 < 0.26.0

运行中镜像、Python 包版本、启动日志或 SBOM

应用层 derender 边界缺失

暴露 /v1/chat/completions/derender 或 /v1/completions/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 或后处理服务调用

与普通 /v1 生成端点一并对外或对大网段开放

身份边界

使用服务身份、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())

对应到本仓库的修复前源码快照,可以把入口坐标固定为下表:

代码位置

作用

安全含义

api_router.py:29

声明 POST /v1/chat/completions/derender

chat derender 的 HTTP Source

api_router.py:54

声明 POST /v1/completions/derender

completion derender 的 HTTP Source

api_router.py:39

route handler 参数为 DerenderChatRequest

FastAPI / Pydantic 会按请求模型绑定 JSON body

api_router.py:64

route handler 参数为 DerenderCompletionRequest

completion 请求体被绑定为嵌套 Pydantic 对象

api_router.py:46

调用 handler.derender_chat_response(request)

请求对象进入服务层

api_router.py:69

调用 handler.derender_completion_response(request)

请求对象进入服务层

api_router.py:49、api_router.py:72

result.model_dump() 后交给 JSONResponse

响应对象会再次被导出和序列化

这些行号不是装饰性引用。它们把“外部请求可达”与“响应会被序列化”都落到了具体代码上,为后面分析 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 self

list[int] 可以拒绝“元素不是整数”的输入,却不会因为列表太长而自动失败;list[GenerateResponse] 可以描述数组元素结构,却不会表达“最多允许多少个 response”。同理,logprobs.content 和 top_logprobs 的形状合法,并不意味着它们的规模适合在当前请求预算中处理。

这就是本漏洞最容易被低估的地方:格式合法的输入,仍然可能是不安全的输入。对资源消耗类漏洞而言,schema 的问题往往不是“没有类型”,而是“类型没有携带上限”。

修复前 schema 的关键坐标如下:

代码位置

字段

缺失的资源语义

protocol.py:176

GenerateResponseChoice

choice 内可携带 token IDs、logprobs 和相邻可控字段

protocol.py:181

token_ids: list[int] | None

没有绑定 max_model_len

protocol.py:215

GenerateResponse

输出结构被表示为可嵌套对象

protocol.py:226

choices: list[GenerateResponseChoice]

没有绑定 VLLM_MAX_N_SEQUENCES

protocol.py:239

DerenderChatRequest

单个 caller-supplied GenerateResponse 进入 chat derender

protocol.py:277

generate_responses: list[GenerateResponse]

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 绑定为 DerenderChatRequest / DerenderCompletionRequest

外部输入被物化为 Pydantic 对象和嵌套 Python 容器

Source

request.generate_response / request.generate_responses 传入服务层

调用方控制的结果对象跨过 HTTP 到应用边界

非充分 Barrier

validate_json_request()

仅检查 Content-Type,不能约束 body 字节数、JSON 深度或字段基数

非充分 Barrier

_check_model(request)

只检查 model 语义,不能限制 response、choice、token 或 logprob 规模

非充分 Barrier

prompt_tokens 与 generate_responses 长度相等检查

保证两个列表对齐,但没有限制列表最大长度

非充分 Barrier

if not choice.token_ids

拒绝空值,但不拒绝超长非空列表

Sink

tokenizer.decode(choice.token_ids, ...)

token 数量转换为 CPU、字符串分配和后续响应大小

Sink

parser.parse(decoded_text, ...)

完整解码文本和 token IDs 继续进入工具 / 推理解析逻辑

Sink

_resolve_logprobs() / _convert_chat_logprobs_to_completion_logprobs()

嵌套 logprob 容器转换为循环、模型复制和字典构造

Sink

result.model_dump() → JSONResponse

已构造响应再次转换为可序列化对象并写出 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 坐标列出来:

代码位置

函数 / 调用点

在漏洞链中的角色

online_derenderer.py:75

OnlineDerenderer.derender_chat

chat derender 主处理函数

online_derenderer.py:98

parser 分支 tokenizer.decode(choice.token_ids, ...)

caller-controlled token IDs 到达 decode

online_derenderer.py:148

plain chat 分支 tokenizer.decode(choice.token_ids, ...)

无 parser 时的主 decode sink

online_derenderer.py:163

OnlineDerenderer.derender_completion

completion derender 主处理函数

online_derenderer.py:188

completion 分支 tokenizer.decode(choice.token_ids, ...)

response × choice 循环内的 decode sink

online_derenderer.py:259

_resolve_logprobs

logprob 结构解析和对象复制

online_derenderer.py:269

遍历 logprobs.content

logprob 外层循环

online_derenderer.py:278

遍历 entry.top_logprobs

top candidates 内层循环

online_derenderer.py:304

_convert_chat_logprobs_to_completion_logprobs

completion 风格 logprob 容器重建

online_derenderer.py:318

构造 top_logprobs 字典

logprob 输出对象放大

资源消耗并不集中在单独一行,而是由多个线性和嵌套处理点共同构成。decode 是最直观的 sink;logprob 转换和响应序列化则说明,只盯着 tokenizer 会低估影响面。

5.1 可控输入面的四个维度

修复前 schema 对字段形状和元素类型做了描述,却没有为这些维度建立业务上限。

字段 / 结构

可控维度

会放大的工作

generate_responses

completion 请求中的 response 数量

completion 外层遍历次数、聚合响应规模

choices

每个 response 的 choice 数量

choice 循环次数、响应 choice 对象数量

token_ids

单个 choice 的 token 数

tokenizer.decode 输入、解码文本长度、响应体大小

prompt_logprobs、logprobs.content、top_logprobs

嵌套列表 / 字典规模

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 漏洞”

tokenizer.decode() 只是被驱动的昂贵操作;没有证据表明 tokenizer 自身越界或错误处理特殊 token

这是 derender 在调用 tokenizer 前缺少输入规模限制

“这是 parser 漏洞”

parser 分支会增加成本,但 plain chat 和 completion 路径也能到达 decode

parser 是可叠加成本,不是唯一根因

“有 API key 就安全”

API key 只证明调用方身份,不能限制单请求触发的循环、decode 和序列化规模

鉴权应与端点级配额、body 限制和字段上限配合

“Pydantic 已经校验,所以不是输入问题”

Pydantic 在旧 schema 中只表达类型和结构,没有表达最大长度

类型正确仍可能资源不安全

“空列表检查说明有边界意识”

completion 的空列表检查位于 derender 调用后,而且只处理空列表,不处理超大非空列表

资源边界必须在昂贵操作前执行,并覆盖上限

“只限制 token_ids 就够了”

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 结果应如何解读

查询

直接证明的事实

结果

ServingToDerenderer.ql

请求字段被传给两个 derender 方法

2 条 flow

TokenIdsToDecode.ql

choice.token_ids 到达 decode 参数

3 条 flow

DerenderCalls.ql

路由、服务层和 decode 的调用边

10 个调用点

LogprobsLoops.ql

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

最关键的原始结果可以直接翻译成如下证据:

结果文件

行号

证明点

serving-to-derenderer.csv

73

request.generate_response 成为 derender_chat 的参数

serving-to-derenderer.csv

125

request.generate_responses 成为 derender_completion 的参数

tokenids-to-decode.csv

98

chat parser 分支的 choice.token_ids 到达 decode

tokenids-to-decode.csv

148

chat plain 分支的 choice.token_ids 到达 decode

tokenids-to-decode.csv

188

completion 分支的 choice.token_ids 到达 decode

derender-calls.csv

46、69

两条 route handler 调用 service 层

derender-calls.csv

97、147、187、232、239、244

derenderer 中相关 decode 调用点

logprobs-loops.csv

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 请求模型

读 api_router.py 的类型注解和路由声明

证明入口存在,不证明字段已到 sink

请求模型包含可控嵌套列表

读 protocol.py 的 Pydantic 字段定义

证明外部 JSON 可表达结构,不证明处理成本

服务层把请求字段交给 derenderer

ServingToDerenderer.ql 与人工读 serving.py

证明跨过 service boundary 的显式传参

choice.token_ids 到达 decode

TokenIdsToDecode.ql 与人工读 online_derenderer.py

证明主要资源 sink 可达

logprob 容器进入循环

LogprobsLoops.ql 与人工读辅助函数

证明叠加型 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。

这个提交改动了三类文件:

文件

改动作用

vllm/entrypoints/scale_out/derender/serving.py

新增 _validate_derender_bounds(),并在 chat / completion 调用 derenderer 前执行

vllm/entrypoints/scale_out/token_in_token_out/protocol.py

为 GenerateResponseChoice.token_ids 增加非负校验

tests/entrypoints/scale_out/derender/test_derender.py

新增资源边界回归测试

其中,与 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 None

chat 路径在 _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 数受服务端序列上限约束

generate_responses 无数量上限

len(generate_responses) <= max_n

derender_completion() 外层循环、默认 prompt token 列表、聚合响应

单个 response 的 choice 数受服务端序列上限约束

choices 无数量上限

len(gen.choices) <= max_n

chat / completion 的 choice 循环、响应对象、usage 聚合

单个 choice 的 token 数不超过模型上下文上限

token_ids 只有类型约束和非空检查

len(token_ids) <= max_model_len

tokenizer.decode()、parser、响应文本与 completion token 统计

logprob content 不超过模型上下文上限

logprobs.content 可直接进入遍历和转换

len(content) <= max_model_len

_resolve_logprobs()、completion logprob 转换、对象复制

单个 token 的 top candidates 有固定上限

top_logprobs 可放大内层遍历

len(top_logprobs) <= 20

嵌套循环、字典构造和响应体

prompt 侧 logprob 不会无限携带

prompt_logprobs 可随响应返回

len(prompt_logprobs) <= max_model_len

响应模型与 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 token_ids

HTTP 400,错误信息包含 max_model_len

超长 token 列表在 decode 前被拒绝

chat choices 数过多

HTTP 400,错误信息包含 choices count

choice 维度不能任意放大

completion generate_responses 数过多

HTTP 400,错误信息包含 generate_responses count

completion 外层 response 维度受服务端上限约束

negative token_ids

HTTP 400

非法 token ID 在协议层被拒绝

oversized logprobs.content

HTTP 400

logprob 内容长度不会越过模型上下文边界

oversized top_logprobs

HTTP 400

单个 logprob entry 的候选项数量不超过 20

completion oversized token_ids

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。

满足下面三项即可判定复现成功:

  1. vulnerable 对两个 derender 路由的 65-ID 请求均返回 HTTP 200;
  2. patched 对同样请求均返回 HTTP 400,且错误包含 max_model_len;
  3. 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 计数增加

合法功能保留

token_ids = max_model_len + 1

200,decode 计数增加

400,decode 计数不变

token 长度 barrier 在 decode 前生效

choices 略超服务端上限(待实现)

旧版真实 vLLM 会进入 choice 处理路径

预期为 400

choice 数量 barrier 生效

generate_responses 略超服务端上限(待实现)

旧版真实 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 常见失败与排查

现象

处理

no configuration file provided

当前目录不对。进入 reproduction/ 后再运行 docker compose ...。

verify 连接失败

先运行 docker compose ps,等待两个服务变为 healthy;仍失败时查看 docker compose logs vulnerable patched。

返回 401

.env 中的 LAB_API_KEY 与容器启动时不一致。执行 docker compose down 后重新 docker compose up -d --build。

端口被占用

修改 .env 里的 VULNERABLE_PORT 或 PATCHED_PORT,再重新启动。

无法拉取基础镜像

检查 Docker 是否运行,以及网络是否允许访问 Docker Hub;离线环境可先在联网机器构建并用 docker save / docker load 迁移镜像。

九、部署侧检测与缓解清单

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 缓解优先级

  1. 修复版本。 将实际运行镜像、依赖锁文件和实例升级到 vLLM >= 0.26.0,而不是只更新构建文件。
  2. 缩小暴露面。 没有业务需求时拒绝 derender 路由;有需求时仅允许可信内部 worker 或明确的服务身份访问。
  3. 多层限额。 网关限制请求 body、JSON 深度、并发和每 key 速率;应用层保留字段长度 / 数量校验;worker 设置可观测的资源隔离与超时。
  4. 部署后验证。 在自有隔离环境执行有界负向测试,确认超限结构在 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

升级到 >= 0.26.0;确认实例版本;按端点配置 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 结构来处理,却没有重新施加同等约束。

归根结底,有三点值得保留。

  1. 资源消耗漏洞不只取决于“有没有输入校验”,还取决于校验是否位于昂贵 sink 之前,以及是否覆盖嵌套数据的每个可放大维度。
  2. scale-out、运维和辅助 API 都是生产攻击面;“调用方可信”应被改写为可执行的服务身份、路由白名单、网络策略和配额。
  3. 静态数据流证据配合低负载差分验证,能够在不向真实服务施压的前提下,清晰验证根因与补丁不变量。

现在最实用的行动项是:盘点两个 derender 路由的暴露情况与调用主体,升级到 0.26.0 或更高版本,补齐网关和最小权限控制,并将“超限输入必须在 decode 前被拒绝”的有界负向测试加入发布后回归。这样,原本依赖内部约定的安全假设才真正成为可验证、可观测、可持续维护的资源边界。

10.1 可复用的辅助 API 审计清单

这类问题不只存在于 derender。任何 render / derender、serialize / deserialize、batch postprocess、trace replay、logprob conversion、tool-call parser 或离线评测接口,只要接收“看起来像内部结果”的对象,都值得按下面的问题审一遍。

审计问题

在本案中的对应点

输入对象的名字是否暗示“内部可信”,但实际可由 HTTP body 提供

GenerateResponse 在 derender 中来自客户端 JSON

对象是否包含列表、字典、token IDs、logprobs、base64 或可嵌套结构

generate_responses、choices、token_ids、logprobs.content、top_logprobs

schema 是否只有类型声明,没有 max_length、服务端常量或模型配置绑定

修复前 list[...] 字段无数量和长度上限

第一次昂贵操作发生在哪里

OnlineDerenderer 中的 tokenizer.decode()、parser、logprob 转换

上限检查是否发生在第一次昂贵操作之前

修复前否;修复后 _validate_derender_bounds() 在服务层先执行

正常主路径的预算是否被辅助路径复用或重新验证

生成路径预算不会自动约束 derender 请求体

响应是否会保留、复制或原样返回可控嵌套字段

choices、解码文本、prompt_logprobs 和 logprob 转换结果进入响应

测试是否覆盖超限负向样例

修复提交新增 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 发布版。