Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Cloudflare's GLM-5.3 leaked other users' prompts through its KV cache. I rebuilt the bug in 30 lines.

Cloudflare published a coordinated disclosure today (October 26) that should be required reading for anyone serving LLMs: for 13 days, its Workers AI deployment of GLM-5.3 (@cf/zai-org/glm-5.3) could return one customer'

Cloudflare published a coordinated disclosure today (October 26) that should be required reading for anyone serving LLMs: for 13 days, its Workers AI deployment of GLM-5.3 (@cf/zai-org/glm-5.3) could return one customer's data inside another customer's response. 155 responses were affected. No exploit was needed — just a very long prompt.

I read the full postmortem, then rebuilt the core mechanism in about 30 lines of Python to see exactly how much of a victim request's context is somebody else's data. The answer is worse than the headline suggests.

The two bugs that had to overlap

Cloudflare is unusually candid about the chain:

  1. A misconfiguration they knew about. GLM-5.3 was deployed with a 1,048,576-token context window, but the KV cache pool on the prefill GPUs only held 681,280 tokens. SGLang sizes that pool at startup from leftover GPU memory; the advertised context length is a separate config value, and nothing forces the two to agree. Cloudflare saw the mismatch before launch, tested long-context requests, got HTTP 200s and coherent-looking streams, and shipped anyway. Their own words: they validated response codes and performance, not output coherence.

  2. A rejection path that lied. In SGLang's disaggregated prefill/decode mode, a prompt that didn't fit the pool was marked rejected, its prompt swapped for a one-token placeholder, and the error recorded for later. But the admission queues on both sides checked finished(), and a rejected request stores its state in to_finish, not finished_reason — so finished() returned False and the dead request walked through the pipeline as if it were alive. Prefill ran the one-token placeholder, transferred one page of KV, and signaled "transfer complete." Decode, which had reserved pages for the original long prompt, started generating while attending over all of them. Only the first page was new. The rest still held the previous requests' data — pages are not zeroed between requests, by design, for performance.

The client got HTTP 200 instead of 400, prompt_tokens matching the long prompt, a coherent reply, delivered suspiciously fast — and containing content from other users' sessions.

I rebuilt it

This is the whole bug, stripped of GPUs. A paged KV pool, tenant A filling 8 pages, then tenant B's rejected request writing its single placeholder token and decode reading back all 8 reserved pages:

PAGE = 16  # tokens per page, scaled down
class Pool:
    def __init__(self, npages): self.pages = [["<empty>"]*PAGE for _ in range(npages)]
    def write(self, pno, toks): self.pages[pno][:len(toks)] = toks
    def read(self, pno): return list(self.pages[pno])

pool = Pool(8)
a_toks = [f"A{i}" for i in range(8*PAGE)]            # tenant A fills all 8 pages
for p in range(8): pool.write(p, a_toks[p*PAGE:(p+1)*PAGE])
pool.write(0, ["B0"])                                 # B: rejected, 1-token placeholder
readback = []
for p in range(8): readback += pool.read(p)           # decode attends over all 8 pages
stale = [t for t in readback if t.startswith("A")]
print(len(stale), len(readback))
pages reserved by B: 8, pages written by B: 1
tokens decode attends over: 128
B's own tokens in readback: 1
stale tokens from tenant A: 127  (99.2% of attended context)
first stale tokens leaked: ['A1', 'A2', 'A3', 'A4', 'A5', 'A6']
real: pool/context = 0.6497 (64.97%)
real: context-pool gap = 367296 tokens; any prompt in (681280, 1048576] triggers
real: fixed pool ~2.04M tokens = 1.95x context

Two details from the simulation are worth pausing on. First, the leak fraction: 99.2% of what the model attended over belonged to a stranger. This wasn't a few stray tokens bleeding into an otherwise normal answer — the "answer" was generated almost entirely from someone else's context, which is why the replies were coherent. The model was, in effect, continuing the previous user's session.

Second, even the one page B did write leaked. The placeholder overwrote exactly one slot of page 0; the other 15 slots still held A's tokens (A1 onwards in the output above). Partial page writes don't clear the rest of the page, and nothing in the pipeline checked that the number of tokens transferred matched the number decode had reserved. Cloudflare's upstream PR (#38704) adds exactly that check.

The arithmetic of the trigger

The vulnerable window is the gap between the two numbers nobody compared:

  • Context window: 1,048,576 tokens
  • Prefill KV pool: 681,280 tokens — 64.97% of the window
  • Any prompt in the 367,296-token band above the pool size hit the rejection path deterministically. Cloudflare is explicit: "Every request over the threshold behaved this way." Not a race. Not probabilistic. A researcher (Gadi Evron of Knostic) reported it September 8; Cloudflare reproduced it with two accounts within 25 minutes of declaring an incident, took the model private 38 minutes later, and restored it the same day with a pool of ~2.04M tokens — 1.95x the context length, so a valid prompt can never reach the rejection path — plus an engine patch that retires rejected requests at the admission queue with their original error.

The timeline also contains the uncomfortable part: SGLang issue #30233 describing this failure mode was filed by another user on July 6, two months before the incident, and the fix existed on SGLang main (#36988, merged September 6) but hadn't shipped in a release Cloudflare was running. The patch line is now v0.5.22, and Cloudflare recommends every SGLang operator upgrade.

What I'm taking from this

Three lessons, in order of how much they'd have helped:

  1. "Rejected" is a state your whole pipeline has to agree on. The bug is a boolean that lived in the wrong field. Every queue that asks "can this proceed?" must ask about rejection, not just completion — especially across a network boundary, where the receiving node can't see the sender's intent, only its signals. "Transfer complete" was technically true and semantically false.

  2. Validate output, not status codes. Cloudflare's pre-launch long-context tests passed because they checked that requests succeeded. A single canary test — send a unique marker in session A, send an over-length prompt in session B, grep B's output for A's marker — would have caught this before launch. It's now the test they validate fixes with. It should be in every multi-tenant inference test suite, alongside the unglamorous invariant: pool size ≥ context length, read from the running engine, not from the config file you think you deployed.

  3. Shared, unzeroed memory is a loan you repay in auditing. Not zeroing KV pages between tenants is the right performance call and the standard one — but it converts every lifetime-tracking bug in the engine into a cross-tenant data leak instead of a crash. Crashes are safer. If your pages outlive their owners, your page-ownership code is security-critical code, and it should be reviewed like it.

155 responses over 13 days is a small blast radius, and Cloudflare found no evidence of deliberate exploitation — the affected requests look like ordinary long-context use. But the class of bug is the story: as inference splits across prefill and decode nodes, the KV cache becomes a distributed shared-memory system with multiple simultaneous owners, and we are rediscovering — in production, with user data — why shared memory is where the worst bugs in computing have always lived.

Sources: Cloudflare, "Cross-request KV cache exposure in the GLM-5.3 Workers AI model, and our remediation with SGLang" (Oct 26, 2026). The simulation is my own, scaled down from the incident's published page mechanics; the incident figures (681,280 / 1,048,576 tokens, 155 responses, ~2.04M-token fixed pool) are Cloudflare's.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.