All vulnerabilities

CVE-2026-71486

PyPI · vllm

Summary

vLLM: Derender endpoints decode caller-supplied GenerateResponse token IDs without output bounds

Advisory details

Summary

The /v1/completions/derender and /v1/chat/completions/derender endpoints accept caller-supplied GenerateResponse objects and postprocess every nested choices[*].token_ids list directly. Unlike the normal render/generate path, derender does not enforce model context length, resolved max_tokens, max_num_seqs, choice-count, or response-size bounds before detokenizing and returning the supplied token IDs. An authenticated API client can therefore make the CPU-only render frontend, or any server exposing these /v1 derender routes, spend CPU and memory proportional to attacker-chosen generated-output-shaped JSON rather than to a bounded generation result.

Technical Details

The render router registers /v1/chat/completions/derender and /v1/completions/derender in vllm/entrypoints/serve/render/api_router.py, and the OpenAI API server attaches this router whenever "generate" or "render" is in supported_tasks (vllm/entrypoints/openai/api_server.py). The routes are under /v1, so they are part of the OpenAI-compatible HTTP API surface and are protected by the API-key middleware when --api-key is configured.

The request types trust generated-output-shaped data from the client. In vllm/entrypoints/serve/disagg/protocol.py, GenerateResponseChoice accepts token_ids: list[int] | None = None, GenerateResponse accepts choices: list[GenerateResponseChoice], and DerenderCompletionRequest accepts generate_responses: list[GenerateResponse]. These fields have no max length, max item count, or relationship to a prior GenerateRequest.

The sink is OnlineDerenderer. derender_completion() iterates every supplied generate_responses entry and every nested choice, calls tokenizer.decode(choice.token_ids, skip_special_tokens=True), appends the decoded text to the response choices, and increments total_completion_tokens from the same supplied list length. derender_chat() has the same shape for a single supplied generate_response, and can also feed the decoded text into tool/reasoning parsers when a parser and chat_request are present. ServingRender.derender_completion_response() calls online_derenderer.derender_completion(request.generate_responses, request.prompt_tokens) before applying any completion-level validation beyond the model check.

Normal render and generation paths derive output limits from max_model_len, the rendered prompt length, request max_tokens / max_completion_tokens, and scheduler limits. Derender bypasses that invariant because it accepts the already-generated output shape directly from the HTTP caller. The missing invariant is: derender should only postprocess bounded generated output, and client-supplied derender payloads must be rejected if their nested generated token/logprob structures exceed the same limits that generation would have enforced.

PoV

The following bounded PoV can be run from a current vLLM checkout containing PR #43606. It asserts the current source facts for the derender routes, unchecked request fields, and decode sink, then simulates the same derender loop with a counting tokenizer. The negative control is a one-choice, 32-token response. The amplified payload keeps the test bounded but demonstrates that all decoded work and returned text scale directly with caller-supplied GenerateResponse contents.

#!/usr/bin/env python3
import subprocess
from dataclasses import dataclass
from pathlib import Path

SOURCE = Path(".")

def require_source_fact(path: str, needles: list[str]) -> None:
    text = (SOURCE / path).read_text()
    missing = [needle for needle in needles if needle not in text]
    if missing:
        raise AssertionError(f"{path} missing expected facts: {missing}")

def source_head() -> str:
    return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=SOURCE, text=True).strip()

@dataclass
class Choice:
    index: int
    token_ids: list[int]

@dataclass
class GenerateResponse:
    request_id: str
    choices: list[Choice]

class CountingTokenizer:
    def __init__(self) -> None:
        self.decode_calls = 0
        self.decoded_ids = 0
    def decode(self, token_ids: list[int], *, skip_special_tokens: bool = True) -> str:
        self.decode_calls += 1
        self.decoded_ids += len(token_ids)
        return "x" * len(token_ids)

def derender_completion_like_current_head(generate_responses: list[GenerateResponse], tokenizer: CountingTokenizer) -> tuple[int, int, int]:
    output_chars = 0
    choices = 0
    total_completion_tokens = 0
    for gen in generate_responses:
        for choice in gen.choices:
            if not choice.token_ids:
                raise ValueError("choice has empty or null token_ids")
            decoded_text = tokenizer.decode(choice.token_ids, skip_special_tokens=True)
            output_chars += len(decoded_text)
            total_completion_tokens += len(choice.token_ids)
            choices += 1
    return choices, total_completion_tokens, output_chars

def make_payload(responses: int, choices_per_response: int, tokens_per_choice: int) -> list[GenerateResponse]:
    token_ids = [42] * tokens_per_choice
    return [GenerateResponse(request_id=f"gen-{r}", choices=[Choice(index=c, token_ids=list(token_ids)) for c in range(choices_per_response)]) for r in range(responses)]

def run_case(name: str, payload: list[GenerateResponse]) -> None:
    tokenizer = CountingTokenizer()
    choices, completion_tokens, output_chars = derender_completion_like_current_head(payload, tokenizer)
    print(f"{name}: responses={len(payload)} choices={choices} decode_calls={tokenizer.decode_calls} decoded_token_ids={tokenizer.decoded_ids} completion_tokens={completion_tokens} output_chars={output_chars}")

require_source_fact("vllm/entrypoints/serve/render/api_router.py", ['"/v1/completions/derender"', '"/v1/chat/completions/derender"', "app.include_router(router)"])
require_source_fact("vllm/entrypoints/serve/disagg/protocol.py", ["class GenerateResponseChoice(BaseModel):", "token_ids: list[int] | None = None", "class GenerateResponse(BaseModel):", "choices: list[GenerateResponseChoice]", "class DerenderCompletionRequest(BaseModel):", "generate_responses: list[GenerateResponse]"])
require_source_fact("vllm/renderers/online_derenderer.py", ["async def derender_completion(", "for gen, pt in zip(generate_responses, prompt_tokens_list):", "for choice in gen.choices:", "decoded_text = tokenizer.decode(", "total_completion_tokens += len(choice.token_ids)"])
print("source_checks=ok")
print(f"source_head={source_head()}")
run_case("negative_control", make_payload(responses=1, choices_per_response=1, tokens_per_choice=32))
run_case("amplified_payload", make_payload(responses=16, choices_per_response=4, tokens_per_choice=8192))
print("observation=derender decodes every caller-supplied token id before any max_model_len, max_tokens, max_num_seqs, or response-size check")

Impact

An attacker with access to the /v1 API can send derender requests that consume CPU and memory in the frontend/postprocessing process and can cause large responses unrelated to any bounded generation. In disaggregated deployments, this affects the CPU-only render frontend; in servers where the render router is attached alongside generation, it affects the same OpenAI-compatible server process that handles normal client traffic. This can degrade availability for other clients sharing the process.

Likely CWE: CWE-400 (Uncontrolled Resource Consumption) / CWE-770 (Allocation of Resources Without Limits or Throttling). Conservative CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L (4.3). This is not Low severity because a regular network API client can induce availability impact in a shared service without local access, invalid model artifacts, or special runtime privileges. If the server is deployed without API-key enforcement for /v1, the privileges component becomes PR:N.

Suggested Fix

Validate derender payloads

References