The Caller Names the Cache Key: SGLang’s mm_hashes Serves One Tenant’s Image to Another

NVD published CVE-2026-111392 at 18:17:38.950Z on 16 October 2026 against SGLang. VulnCheck is the assigning CNA, the record is vulnStatus: Deferred, and the CVSS 4.0 score is 6.3 medium on the vector AV:N/AC:H/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N, classified CWE-345, insufficient verification of data authenticity. The affected range is through 0.5.21.

The description is four lines and they are worth reading slowly, because the mechanism is not a memory-safety bug or an injection: “SGLang through 0.5.21 trusts caller-supplied mm_hashes values as the multimodal prefix-cache key, allowing remote attackers to poison cached KV entries across requests. Attackers can send a different image under a known or predicted hash via /generate to disclose another request’s image content or corrupt victims’ responses.”

An inference server’s prefix cache is keyed on content. This one accepts the key from the network. We have covered cache poisoning in an inference stack before; this is the variant where no collision has to be engineered, because the server asks the caller what the key should be.

The field, and the comment that documents it

We read python/sglang/srt/managers/io_struct.py at the v0.5.21 tag. The field is declared on the generate-request dataclass with ten lines of explanation, quoted verbatim:

    # Optional per-image hashes the caller has already computed (hex strings).
    # Single request: one hash per image. Batch request: either one hash per
    # request when each request has one image, or one list of hashes per request.
    # When supplied, each MultimodalDataItem's
    # `hash` is initialised from this list and `set_pad_value` skips the
    # internal `hash_feature()` recompute, so the resulting `pad_value` is
    # deterministic from the caller's hash. Intended for external KV routers
    # that compute their own per-image hash for routing decisions and need
    # sglang's prefix-cache key to align. When unset, behavior is unchanged
    # (sglang hashes the processor feature tensor).
    mm_hashes: Optional[Union[List[str], List[List[str]]]] = None

This is a legitimate engineering need, honestly described. In a disaggregated deployment, a router in front of several SGLang replicas decides which replica should serve a request, and it wants that decision to agree with the replica’s own cache key for the same image — otherwise it routes for a cache hit that does not exist. Letting the router declare the hash is the direct solution.

The application of that value, in python/sglang/srt/managers/tokenizer_manager.py at the same tag, is equally candid about intent:

            caller_mm_hashes = getattr(obj, "mm_hashes", None)
            if caller_mm_hashes and mm_inputs and mm_inputs.mm_items:
                if len(caller_mm_hashes) != len(mm_inputs.mm_items):
                    logger.warning(...)
                else:
                    for item, hex_hash in zip(mm_inputs.mm_items, caller_mm_hashes):
                        if not isinstance(item, MultimodalDataItem):
                            continue
                        try:
                            item.set_hash(int(hex_hash, 16))
                        except (TypeError, ValueError):
                            logger.warning(...)

There is validation here, and it is all of the wrong kind. The length is checked. The type is checked. The hex parse is wrapped. The in-source comment even explains the failure policy: “On any per-item parse error or list-length mismatch we fall back to the internal recompute so a malformed mm_hashes never blocks a request.” Every one of those guards protects the server from a malformed value. None of them asks whether the caller is entitled to name this image’s cache identity at all. The value is well-formed hex; the question of whether it is true never arises.

Why a hash becomes a disclosure

The chain is short and is laid out in the public issue. MultimodalDataItem.hash determines the item’s pad_value. The image region of the token sequence is that pad value repeated. The prefix cache is keyed on the token sequence. Therefore the declared hash is the cache identity of the image segment: two requests carrying the same mm_hashes value produce matching tokens for the image region, and the second one hits the first one’s cached KV regardless of what pixels it actually sent.

The reporter, @yyymk, filed issue #44641 at 03:12:08Z on 16 October 2026 with a reproduction built on two 512×256 images — one containing the text IMAGE-A-SECRET-7f3a9c, one containing IMAGE-B-DISTRACT-2c1b4d — against Qwen2.5-VL-3B-Instruct on a single RTX 4090. Three calls, identical prompt text, temperature zero. The observed result, quoted verbatim from the issue:

print(gen("A.png", "deadbeef00000001"))  # "IMAGE-A-SECRET-7f3a9c ...", cached_tokens 15
print(gen("B.png", "aaaa0000bbbb0002"))  # "IMAGE-B-DISTRACT-2c1b4d ...", cached_tokens 15
print(gen("B.png", "deadbeef00000001"))  # "IMAGE-A-SECRET-7f3a9c ...", cached_tokens 193

The third request sends image B and transcribes image A’s text. The reporter notes the aliasing is symmetric — cache B first, then send A under the same declared hash, and B comes back — and that it reproduced on three of three cold restarts. The jump in cached_tokens from 15 to 193 is the 162-token image region being served from the cache rather than computed.

The part that makes this a cross-tenant problem rather than a self-inflicted one is a single sentence in the report: “The radix cache is shared across clients, so the aliasing is not limited to one connection” — and the two-host check that follows, in which a second client sending a plain grey image under the same declared hash received the first client’s image content, cached_tokens 190.

The response looks perfect

We want to dwell on the detection problem, because it is the part that survives this particular CVE. From the issue, verbatim: “The response is a normal completion — no warning, no error, finish_reason is a regular stop.” And the poisoned request completed faster than the uncached baselines, because a cache hit is the whole point of a cache hit.

So the signal available to a victim is: a successful HTTP 200, a well-formed completion, a normal stop reason, and better-than-average latency. There is no malformed input to log, no exception to trace, no rate anomaly, and no authentication failure. An image-understanding endpoint that returns a confident and entirely wrong description of a document is, from every observability surface a platform team normally has, indistinguishable from one that is working. If the deployment sits behind an OCR or document-processing pipeline, the downstream record of what the model “saw” is the only artefact, and it is the forged one.

The CVSS vector is, on reflection, defensible and worth parsing. AC:H and AT:P encode the real preconditions: the attacker must know or predict a hash the victim used, and the multimodal path must be in use on the native endpoint. VC:L/VI:L — low confidentiality and integrity impact — reads conservatively against a demonstrated cross-client image disclosure, but it is scoring the per-request leak, not an aggregate. We record the disagreement rather than resolving it: what you lose is one victim’s image per correctly guessed hash, and what the hash is depends entirely on what the router in front of the server chose to compute. An external KV router that derives its hash from, say, a document identifier or an object-storage key is handing an attacker a predictable namespace.

Two notes on scope, both from the reporter

The report is careful about blast radius in a way we will repeat rather than paraphrase, because getting this wrong in either direction is costly.

First: “the field has no flag gating or allowlist, and the OpenAI-compatible endpoints do not expose it, so this is native /generate only.” A deployment that fronts SGLang exclusively with the /v1/chat/completions surface does not reach the field. That exclusion is doing a great deal of work in practice, and anyone triaging this should establish which endpoint their clients actually use before escalating.

Second, and this is the finding we would lead with: the same dataclass already contains a field of exactly this kind that is gated. Immediately below mm_hashes in io_struct.py at v0.5.21:

    # Optional `sha256:<64-hex>` identities for the original media contents. Unlike
    # mm_hashes, these identify processor inputs and never replace the
    # processor-output feature hash used by the embedding/prefix cache.
    mm_content_hashes: Optional[

And in python/sglang/srt/arg_groups/fields/mm.py at the same tag, that neighbouring field has a server flag, defaulting off, with a warning attached:

    trust_mm_content_hashes: A[
        bool,
        "Trust caller-provided multimodal SHA-256 content hashes. This can "
        "skip reading media on a hot metadata-cache hit; only enable it when "
        "the caller guarantees that hashes identify immutable media bytes.",
    ] = False

The project understood the principle. It wrote the principle into a help string — only enable it when the caller guarantees — and shipped the flag that enforces it. Then the adjacent field, whose effect on the cache is more direct rather than less, took the caller’s word unconditionally. The word trust appears in one identifier and not the other, and that is the entire difference between the two code paths.

We also confirmed the reporter’s related observation first-hand: the embedding request dataclass, EmbeddingReqInput, has no mm_hashes field at all at v0.5.21. It carries image_data, video_data and the rest, and no caller-declared hash. The capability exists on exactly one of the two request types, and it is the one that generates text.

A fix exists, opened thirty minutes after the report, and is not merged

The timeline is tight and, so far, familiar. Issue #44641 filed 03:12:08Z. At 03:21:38Z — nine and a half minutes later — a second contributor, @Kylinny, commented offering to take it on, naming the approach in advance: opt-in behind a server flag, default path recomputes the content hash. At 03:42:07Z, thirty minutes after the report, pull request #44653 was opened: “[Bug] Gate caller mm_hashes behind --trust-caller-mm-hashes”, 143 added and 35 deleted lines across five files, one of which is a new unit test.

The design invariants in the PR description are the right ones, and the first is quoted verbatim: “A caller-declared hash must never become a cache identity by default — it is untrusted input from the network, and the radix cache is shared across tenants.” The second is the one that distinguishes a real fix from a partial one: “The flag is the only gate: there is no per-request allowlist, no partial application. With the flag off, MultimodalDataItem.hash stays None until the content hash is computed, so no code path can observe the declared value.” The external-KV-router use case survives when the flag is set.

The author is explicit about the limits of their own verification, which we note approvingly: they had no GPU available and verified at the logic level against the real MultimodalDataItem class driven with mock items, rather than claiming an end-to-end reproduction they did not run.

At the time of writing, issue #44641 is open and pull request #44653 is open and unmerged. We checked tokenizer_manager.py on main directly: the unconditional item.set_hash(int(hex_hash, 16)) is still there, now at line 1269, inside the same caller_mm_hashes block. There is no fixed version. The CVE names no patched release because none exists.

This is the second SGLang record in twelve hours with that exact shape. CVE-2026-111391, the debug-dumper eval() flaw published at 12:16Z this morning, also has a community fix that arrived within the hour and also has no merged patch — in that case because the PR was swept up in an anti-queue-flooding account block. These are different contributors, different components and different causes, and we are not claiming a single explanation. What we are recording is the operational consequence for anyone running SGLang: for two separate open CVEs today, the authoritative place to learn that a fix has landed is an open GitHub issue, not a release feed, not an advisory, and not a scanner.

What to do

  • Establish which endpoint your clients use. If every caller goes through the OpenAI-compatible surface, the field is not exposed and you are not reachable by this. If anything speaks native /generate — which is common for routers, batch jobs and internal tooling — you are.
  • Strip mm_hashes at the gateway. There is no server-side flag to turn this off on any released version, so the control has to sit in front of the server. A request-body filter that removes the key from POST /generate restores content-based hashing with no code change. If you run an external KV router that genuinely needs the alignment, allow the field only on the router’s own path and reject it on every client-facing one.
  • Treat the prefix cache as a shared-tenancy boundary and check whether it is one. The radix cache spans clients by design. If your multimodal deployment serves more than one customer, team or trust level from the same replica, the blast radius of any cache-identity bug is the whole set — which is an argument for replica-per-tenant on multimodal paths independent of this CVE.
  • Audit how your router derives its hashes. The attack needs a known or predicted hash. A router hashing raw image bytes produces an unpredictable namespace; one deriving from a document ID, an S3 key or a tenant-scoped counter produces a guessable one. This is the difference between AC:H and something considerably easier.
  • Do not expect this to appear in a scan. The record is vulnStatus: Deferred, so no NVD analysis is coming, and no fixed version exists to compare against. Version-based detection yields nothing actionable; the actionable check is whether the field is reachable.
  • Watch #44641 and #44653. When the gate merges, the flag to know is --trust-caller-mm-hashes, defaulting off. Until then, assume every released version applies the caller’s value.
  • Reconsider what you logged. Because a poisoned response is well-formed and fast, past exposure leaves no error to search for. If you process documents or images through this path and the integrity of “what the model saw” matters downstream, the review question is whether any consumer of those outputs can tolerate silent substitution — not whether anything in your logs looks wrong.

The generalisable rule is one we have now written in several shapes, most recently about SGLang’s multimodal resource-exhaustion batch and about renovate’s preset inheritance: a cache key is an authorisation decision wearing a performance costume. Whoever controls the key controls which stored computation a request is entitled to read. Accepting it from the network is the same act as accepting a session identifier from the network, and it deserves the same scrutiny — including, as the neighbouring field in this very file demonstrates, a flag with the word trust in its name.

Verification note. The CVE identifier, publication timestamp (18:17:38.950Z, 16 October 2026), vulnStatus: Deferred, assigning CNA, CVSS 4.0 score and vector, CWE-345 assignment, affected range, description and reference list were read first-hand from the NVD 2.0 API; the CVE description is quoted verbatim. All Python — the mm_hashes and mm_content_hashes declarations and their comments from managers/io_struct.py, the caller-hash application block from managers/tokenizer_manager.py, and the trust_mm_content_hashes field from arg_groups/fields/mm.py — was fetched directly at the v0.5.21 tag and is quoted verbatim including comments and docstrings. The absence of mm_hashes from EmbeddingReqInput at that tag was checked directly. The continued presence of the unconditional set_hash call on main was checked directly at the time of writing. Issue #44641’s title, author, creation timestamp, open state, reproduction, observed outputs, cached_tokens figures, environment block and scope notes, and both of its comments, and pull request #44653’s title, author, creation timestamp, open and unmerged state, diff statistics, file list and description, were read first-hand from the GitHub API and quoted verbatim where quoted. Brave search was used for discovery only and returned CVE mirrors and automated scoring pages with no independent technical analysis; one aggregator reprints a different severity figure than NVD records, and we use NVD’s. We did not install SGLang, launch a server, load a model, send any request to any /generate endpoint, construct any image, or author or submit any mm_hashes value; no proof of concept was executed and the mechanism is described only to the extent already published in the open issue. All timings, token counts and transcription results are the reporter’s measurements, not ours. We have no evidence of exploitation and imply none.

Sources: