{"schema":"threatline.record/2","record":{"id":"GHSA-rh6f-3x46-j33q","aliases":["GHSA-rh6f-3x46-j33q"],"summary":"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)","details":"## Severity\r\n\r\n- **Severity:** Moderate (highest of the two sites)\r\n- **CVSS v3.1:**\r\n  - Site A — EXIF `ImageID` multimodal alias: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H` (6.5, Moderate)\r\n  - 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)\r\n- **CWE:** CWE-807 (Reliance on Untrusted Inputs in a Security Decision), CWE-345 (Insufficient Verification of Data Authenticity), CWE-284 (Improper Access Control)\r\n\r\n## Affected\r\n\r\n- **Ecosystem / package:** pip / `vllm`\r\n- **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.\r\n\r\n## Summary\r\n\r\nvLLM 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.**\r\n\r\n1. **Site A — EXIF `ImageID` becomes the multimodal cache key.** The multimodal hasher treats a UUID-valued EXIF `ImageID` as 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 explicit `multi_modal_uuids` interface is meant to carry caller-chosen identity.\r\n2. **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 LoRA `foo` and a base-model request with `cache_salt=\"foo\"` both yield first-block `extra_keys = (\"foo\",)` and the identical `BlockHash`. KV state produced under a LoRA can then be served to a base request (or vice-versa), defeating the documented `cache_salt` isolation guarantee. The effect is confined to the first block, because `cache_salt` is injected only at `start_token_idx == 0` while the LoRA name persists on every block, so chained hashes diverge after block 0.\r\n\r\nPrefix 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.\r\n\r\nBoth sites are distinct from published fixes in the same areas: Site A survives **[GHSA-c65p-x677-fgj6](https://github.com/vllm-project/vllm/security/advisories/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](https://github.com/vllm-project/vllm/security/advisories/GHSA-rm76-4mrf-v9r8)** ([CVE-2025-25183](https://nvd.nist.gov/vuln/detail/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.\r\n\r\n## Affected code\r\n\r\nLinks pinned to the confirmed commit [`752a3a504485`](https://github.com/vllm-project/vllm/tree/752a3a504485790a2e8491cacbb35c137339ad34) (v0.25.1).\r\n\r\n**Site A — EXIF `ImageID` multimodal cache alias:**\r\n\r\n- The hasher returns `ImageID.bytes` instead 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`).\r\n- 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`).\r\n- 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_item` at [`vllm/multimodal/cache.py#L352`](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/multimodal/cache.py#L352)).\r\n\r\nBoth image branches short-circuit to the caller-supplied EXIF UUID bytes *before* any content is serialized:\r\n\r\n```python\r\n# vllm/multimodal/hasher.py Lines 61-84\r\n        if isinstance(obj, Image.Image):\r\n            exif = obj.getexif()\r\n            if Image.ExifTags.Base.ImageID in exif and isinstance(\r\n                exif[Image.ExifTags.Base.ImageID], uuid.UUID\r\n            ):\r\n                return (exif[Image.ExifTags.Base.ImageID].bytes,)\r\n\r\n            data = {\"mode\": obj.mode, \"data\": np.asarray(obj)}\r\n            palette = obj.palette\r\n            if palette is not None:\r\n                data[\"palette\"] = palette.palette\r\n                if palette.rawmode is not None:\r\n                    data[\"palette_rawmode\"] = palette.rawmode\r\n\r\n            return cls.iter_item_to_bytes(\"image\", data)\r\n\r\n        if isinstance(obj, MediaWithBytes) and isinstance(obj.media, Image.Image):\r\n            exif = obj.media.getexif()\r\n            if Image.ExifTags.Base.ImageID in exif and isinstance(\r\n                exif[Image.ExifTags.Base.ImageID], uuid.UUID\r\n            ):\r\n                return (exif[Image.ExifTags.Base.ImageID].bytes,)\r\n\r\n            return cls.iter_item_to_bytes(\"image\", obj.original_bytes)\r\n```\r\n\r\n**Site B — untyped prefix-cache extra keys**, in `vllm/v1/core/kv_cache_utils.py`:\r\n\r\n- [`_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).\r\n- 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`), with `cache_salt` added only at the first block ([`#L560-L561`](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/core/kv_cache_utils.py#L560-L561)).\r\n- 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.\r\n\r\nThe 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:\r\n\r\n```python\r\n# vllm/v1/core/kv_cache_utils.py Lines 559-568\r\n    lora_extra_keys: list[str] = _gen_lora_extra_hash_keys(request)\r\n    cache_salt_keys: list[str] = (\r\n        [request.cache_salt] if (start_token_idx == 0 and request.cache_salt) else []\r\n    )\r\n    prompt_embeds_keys = _gen_prompt_embeds_extra_hash_keys(\r\n        request, start_token_idx, end_token_idx\r\n    )\r\n\r\n    extra_keys: list[Any] = (\r\n        lora_extra_keys + mm_extra_keys + cache_salt_keys + prompt_embeds_keys\r\n    )\r\n```\r\n\r\n`_gen_lora_extra_hash_keys` returns the bare name with no domain tag:\r\n\r\n```python\r\n# vllm/v1/core/kv_cache_utils.py Lines 508-510\r\n    if not request.lora_request:\r\n        return []\r\n    return [request.lora_request.lora_name]\r\n```\r\n\r\n## Impact\r\n\r\nCross-request cache aliasing on the default multimodal and prefix-cache paths. Neither site demonstrates code execution.\r\n\r\n- **Site A** — an attacker who can set an image's EXIF `ImageID` makes 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).\r\n- **Site B** — a base-model request whose `cache_salt` equals 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 own `cache_salt` equal to that public LoRA name (the base→LoRA direction needs no victim secret); prompt ≥ one full block.\r\n\r\n\r\n## Suggested Fix\r\n\r\nDerive shared-cache identity from content and principal, not from caller-controllable values:\r\n\r\n- **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_uuids` interface, where that trust decision is visible. Drop the EXIF-`ImageID`→hash substitution from both image branches of `serialize_item` (and the now-unused `import uuid`):\r\n\r\n```diff\r\n--- a/vllm/multimodal/hasher.py\r\n+++ b/vllm/multimodal/hasher.py\r\n         if isinstance(obj, Image.Image):\r\n-            exif = obj.getexif()\r\n-            if Image.ExifTags.Base.ImageID in exif and isinstance(\r\n-                exif[Image.ExifTags.Base.ImageID], uuid.UUID\r\n-            ):\r\n-                return (exif[Image.ExifTags.Base.ImageID].bytes,)\r\n-\r\n             data = {\"mode\": obj.mode, \"data\": np.asarray(obj)}\r\n             ...\r\n         if isinstance(obj, MediaWithBytes) and isinstance(obj.media, Image.Image):\r\n-            exif = obj.media.getexif()\r\n-            if Image.ExifTags.Base.ImageID in exif and isinstance(\r\n-                exif[Image.ExifTags.Base.ImageID], uuid.UUID\r\n-            ):\r\n-                return (exif[Image.ExifTags.Base.ImageID].bytes,)\r\n-\r\n             return cls.iter_item_to_bytes(\"image\", obj.original_bytes)\r\n```\r\n\r\n- **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:\r\n\r\n```diff\r\n--- a/vllm/v1/core/kv_cache_utils.py\r\n+++ b/vllm/v1/core/kv_cache_utils.py\r\n     if not request.lora_request:\r\n         return []\r\n-    return [request.lora_request.lora_name]\r\n+    return [(\"lora\", request.lora_request.lora_name)]\r\n...\r\n-    cache_salt_keys: list[str] = (\r\n-        [request.cache_salt] if (start_token_idx == 0 and request.cache_salt) else []\r\n+    cache_salt_keys: list[tuple[str, str]] = (\r\n+        [(\"cache_salt\", request.cache_salt)]\r\n+        if (start_token_idx == 0 and request.cache_salt)\r\n+        else []\r\n     )\r\n```\r\n\r\nAdd 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.\r\n\r\nThese 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.\r\n\r\n## Credit\r\n\r\n**Reported by:** Patch the Planet (Trail of Bits + OpenAI collaboration)\r\n\r\nThese vulnerabilities were discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.\r\n\r\n---\r\n\r\nA public patch is available in: https://github.com/vllm-project/vllm/pull/51899\r\n","categories":["Unclassified"],"product":"vllm","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/vllm-project/vllm/security/advisories/GHSA-rh6f-3x46-j33q","source_modified_at":"2026-10-06T07:08:41Z","fetched_at":"2026-10-23T19:00:29.328130Z","package":{"ecosystem":"pip","name":"vllm"},"range":"< 0.31.0"}],"fixed_versions":[],"cvss":6.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-06T07:08:41Z","modified_at":"2026-10-06T07:08:41Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/vllm-project/vllm/security/advisories/GHSA-rh6f-3x46-j33q","fetched_at":"2026-10-23T19:00:29.328130Z","source_modified_at":"2026-10-06T07:08:41Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-06T07:08:41Z","content_hash":"f482cb24d2a337b26ee70324e5f20f12ae432cde2c918ed344114eb2b4720e8d"}}
