GitHub Advisoryadvisory · PyPI

GHSA-8737-qx52-hjff vLLM: Derender endpoints decode caller-supplied GenerateResponse token IDs without output bounds

MEDIUM Published 2026-09-04 Modified 2026-09-04 Official page ↗
Aliases
CVE-2026-71486
Ecosystem
PyPI
Packages
vllm
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L
Weaknesses
CWE-400, CWE-770
Reviewed
2026-09-04

Affected packages

• PyPI vllm: < 0.26.0 → fixed in 0.26.0

Description

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. ```python !/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 before any deto…

Related

Alias · CVE (1)

  • CVE-2026-71486 vLLM is an inference and serving engine for large language models. vulnerability

Weakness · CWE (2)

  • CWE-400 Uncontrolled Resource Consumption weakness
  • CWE-770 Allocation of Resources Without Limits or Throttling weakness

References