vllm: Caller-controllable values become or collide with shared cache keys, enabling cross-request cache aliasing (2 sites: multimodal EXIF hash and prefix-cache extra keys)
What fixes it
No source in this snapshot names a fixed version.
Description
Severity
- Severity: Moderate (highest of the two sites)
- CVSS v3.1:
- Site A — EXIF
ImageIDmultimodal alias:CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H(6.5, Moderate) - Site B — untyped prefix-cache extra keys:
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N(3.1, Low) - CWE: CWE-807 (Reliance on Untrusted Inputs in a Security Decision), CWE-345 (Insufficient Verification of Data Authenticity), CWE-284 (Improper Access Control)
Affected
- Ecosystem / package: pip /
vllm - Affected versions: vLLM ≤ 0.25.1 (confirmed on 0.25.1, commit [
752a3a504485](https://github.com/vllm-project/vllm/tree/752a3a504485790a2e8491cacbb35c137339ad34)). The lower bound predates 0.25.1; maintainers can confirm how far back each cache-key derivation reaches.
Summary
vLLM keys shared cross-request caches on values that a caller can control or influence, without binding those values to a principal or to the actual media content. Two distinct caches are affected by the same root cause — cache identity is derived from a caller-controllable value with insufficient binding/namespacing, so a chosen or forged value aliases another request's shared cache state.
- **Site A — EXIF
ImageIDbecomes the multimodal cache key.** The multimodal hasher treats a UUID-valued EXIFImageIDas the *entire* hash input for an image. Two visually different request images that carry the same EXIF UUID therefore produce the same multimodal hash, and the processor cache returns the first request's cached item for the second. Ordinary requests are supposed to be keyed on actual content; only the explicitmulti_modal_uuidsinterface is meant to carry caller-chosen identity. - Site B — untyped prefix-cache extra keys collide across domains. The prefix-cache block-hash builder flattens LoRA name, multimodal hashes, first-block
cache_salt, and prompt-embed hashes into a single untyped tuple. Because the domains are not tagged, equal strings from different fields collide: a request under static LoRAfooand a base-model request withcache_salt="foo"both yield first-blockextra_keys = ("foo",)and the identicalBlockHash. KV state produced under a LoRA can then be served to a base request (or vice-versa), defeating the documentedcache_saltisolation guarantee. The effect is confined to the first block, becausecache_saltis injected only atstart_token_idx == 0while the LoRA name persists on every block, so chained hashes diverge after block 0.
Prefix caching is enabled by default (enable_prefix_caching=True), and the multimodal processor cache is on the default multimodal path; neither site requires an opt-in feature.
Both sites are distinct from published fixes in the same areas: Site A survives GHSA-c65p-x677-fgj6 (whose fix adds image size/mode/info to tobytes() serialization) because the EXIF-ImageID→hash branch short-circuits *before* content is serialized and returns caller-supplied UUID bytes; Site B survives GHSA-rm76-4mrf-v9r8 (CVE-2025-25183, which seeds hash() to remove predictability) because this is a preimage collision from untyped domain keys that yields identical hashes regardless of seed or hash algorithm.
Affected code
Links pinned to the confirmed commit [752a3a504485](https://github.com/vllm-project/vllm/tree/752a3a504485790a2e8491cacbb35c137339ad34) (v0.25.1).
**Site A — EXIF ImageID multimodal cache alias:**
- The hasher returns
ImageID.bytesinstead of image content when EXIF contains a UUID: [vllm/multimodal/hasher.py#L52](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/multimodal/hasher.py#L52) (serialize_item) and [vllm/multimodal/hasher.py#L154](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/multimodal/hasher.py#L154) (hash_kwargs). - That value becomes the processor-cache key: [
vllm/multimodal/processing/inputs.py#L25](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/multimodal/processing/inputs.py#L25) (get_mm_hashes). - The cache returns the first cached item when a later request presents the same hash: [
vllm/multimodal/cache.py#L326](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/multimodal/cache.py#L326) (MultiModalProcessorOnlyCache;get_and_update_itemat [vllm/multimodal/cache.py#L352](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/multimodal/cache.py#L352)).
Both image branches short-circuit to the caller-supplied EXIF UUID bytes *before* any content is serialized:
# vllm/multimodal/hasher.py Lines 61-84
if isinstance(obj, Image.Image):
exif = obj.getexif()
if Image.ExifTags.Base.ImageID in exif and isinstance(
exif[Image.ExifTags.Base.ImageID], uuid.UUID
):
return (exif[Image.ExifTags.Base.ImageID].bytes,)
data = {"mode": obj.mode, "data": np.asarray(obj)}
palette = obj.palette
if palette is not None:
data["palette"] = palette.palette
if palette.rawmode is not None:
data["palette_rawmode"] = palette.rawmode
return cls.iter_item_to_bytes("image", data)
if isinstance(obj, MediaWithBytes) and isinstance(obj.media, Image.Image):
exif = obj.media.getexif()
if Image.ExifTags.Base.ImageID in exif and isinstance(
exif[Image.ExifTags.Base.ImageID], uuid.UUID
):
return (exif[Image.ExifTags.Base.ImageID].bytes,)
return cls.iter_item_to_bytes("image", obj.original_bytes)Site B — untyped prefix-cache extra keys, in vllm/v1/core/kv_cache_utils.py:
- [
_gen_lora_extra_hash_keys(#L498)](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/core/kv_cache_utils.py#L498) returns[request.lora_request.lora_name](a bare string). - The untyped flat concatenation is in
generate_block_hash_extra_keys: [#L559-L568](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/core/kv_cache_utils.py#L559-L568) (lora_extra_keys + mm_extra_keys + cache_salt_keys + prompt_embeds_keys), withcache_saltadded only at the first block ([#L560-L561](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/core/kv_cache_utils.py#L560-L561)). - The collision lands in the hash preimage before hashing: [
hash_block_tokens(#L577-L581)](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/core/kv_cache_utils.py#L577-L581). Because the collision is in the preimage, changing the hash algorithm does not help.
The bare LoRA name, first-block cache_salt, and the other domains are flattened into one untyped list, so equal strings from different fields are indistinguishable in the preimage:
# vllm/v1/core/kv_cache_utils.py Lines 559-568
lora_extra_keys: list[str] = _gen_lora_extra_hash_keys(request)
cache_salt_keys: list[str] = (
[request.cache_salt] if (start_token_idx == 0 and request.cache_salt) else []
)
prompt_embeds_keys = _gen_prompt_embeds_extra_hash_keys(
request, start_token_idx, end_token_idx
)
extra_keys: list[Any] = (
lora_extra_keys + mm_extra_keys + cache_salt_keys + prompt_embeds_keys
)_gen_lora_extra_hash_keys returns the bare name with no domain tag:
# vllm/v1/core/kv_cache_utils.py Lines 508-510
if not request.lora_request:
return []
return [request.lora_request.lora_name]Impact
Cross-request cache aliasing on the default multimodal and prefix-cache paths. Neither site demonstrates code execution.
- Site A — an attacker who can set an image's EXIF
ImageIDmakes two different images collide in the processor cache, so a later request is silently served the first request's cached processed media (cross-request substitution / state-integrity failure). - Site B — a base-model request whose
cache_saltequals a public static LoRA name reuses that LoRA's first-block KV state (and vice-versa), breaking the documented guarantee that only same-salt requests reuse cached KV. Confined to the first block, so it is state confusion / a partial oracle rather than full-prompt substitution. Preconditions: prefix caching enabled (default), a known static LoRA name, and the attacker setting their owncache_saltequal to that public LoRA name (the base→LoRA direction needs no victim secret); prompt ≥ one full block.
Suggested Fix
Derive shared-cache identity from content and principal, not from caller-controllable values:
- Site A — hash the actual image bytes / pixel content for ordinary requests; do not silently substitute EXIF metadata for the content hash. Reserve stable caller-chosen identity for the explicit
multi_modal_uuidsinterface, where that trust decision is visible. Drop the EXIF-ImageID→hash substitution from both image branches ofserialize_item(and the now-unusedimport uuid):
--- a/vllm/multimodal/hasher.py
+++ b/vllm/multimodal/hasher.py
if isinstance(obj, Image.Image):
- exif = obj.getexif()
- if Image.ExifTags.Base.ImageID in exif and isinstance(
- exif[Image.ExifTags.Base.ImageID], uuid.UUID
- ):
- return (exif[Image.ExifTags.Base.ImageID].bytes,)
-
data = {"mode": obj.mode, "data": np.asarray(obj)}
...
if isinstance(obj, MediaWithBytes) and isinstance(obj.media, Image.Image):
- exif = obj.media.getexif()
- if Image.ExifTags.Base.ImageID in exif and isinstance(
- exif[Image.ExifTags.Base.ImageID], uuid.UUID
- ):
- return (exif[Image.ExifTags.Base.ImageID].bytes,)
-
return cls.iter_item_to_bytes("image", obj.original_bytes)- Site B — tag each extra-key domain so equal strings from different fields become distinct objects (
("lora", name)vs("cache_salt", salt), and likewise("mm", …)/("prompt_embeds", …)); the untyped collision then vanishes from the preimage:
--- a/vllm/v1/core/kv_cache_utils.py
+++ b/vllm/v1/core/kv_cache_utils.py
if not request.lora_request:
return []
- return [request.lora_request.lora_name]
+ return [("lora", request.lora_request.lora_name)]
...
- cache_salt_keys: list[str] = (
- [request.cache_salt] if (start_token_idx == 0 and request.cache_salt) else []
+ cache_salt_keys: list[tuple[str, str]] = (
+ [("cache_salt", request.cache_salt)]
+ if (start_token_idx == 0 and request.cache_salt)
+ else []
)Add a regression test exercising the real generate_block_hash_extra_keys() helper: distinct-domain requests must differ ((("lora", "foo"),) vs (("cache_salt", "foo"),)) while same-domain requests still match, with multimodal and prompt-embed coverage. The Site A test asserts two images with the same EXIF ImageID but different content no longer hash equal. Both changes apply cleanly to v0.25.1.
These two sites are filed together because they share one root cause; they can be split into separate per-component advisories (multimodal hasher vs. prefix-cache key builder) if the vLLM team prefers to track and fix them independently.
Credit
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
These vulnerabilities were discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
---
A public patch is available in: https://github.com/vllm-project/vllm/pull/51899
Text from GitHub Advisory Database
Affected versions 1
- vllm, pip< 0.31.0GitHub