Cloudflare Workers AI Security Postmortem: een runbook voor cross-tenant KV-cachelekken
Kort samengevat
Ontdek hoe je cross-tenant KV-cachelekken in je game-backend detecteert en verhelpt, aan de hand van Cloudflares GLM-5.3-incident en de SGLang-fix.
Een AI-feature voor spelers gaf een antwoord terug met daarin prompttekst van een andere studio. Geen crash. Geen 500. Een coherent, relevant antwoord, afgeleverd met HTTP 200 en een tokentelling die overeenkwam met het eigen verzoek van de beller.
Dat is de slechtste vorm die een multi-tenant bug kan aannemen: de output is plausibel, dus niemand maakt een ticket aan. Cloudflare overkwam dit exact op 27 augustus 2026, toen 155 antwoorden van het GLM-5.3 Workers AI-model gegevens van sessies van andere klanten bevatten. Elk getroffen verzoek was langer dan 681.280 tokens. De fix vereiste twee wijzigingen — één configuratie, één engine-patch — en het detectieverhaal is leerzamer dan de fix.
Hier is het runbook: wat er brak, hoe je dezelfde foutklasse in je stack detecteert, hoe je het remedieert en hoe je herhaling voorkomt.
Wat er daadwerkelijk brak
Twee onafhankelijke problemen overlapten. Elk afzonderlijk was overleefbaar geweest.
Probleem één: een capaciteitsmismatch waar geen assert op zat
Cloudflare deployde GLM-5.3 met een contextvenster van 1.048.576 tokens. De KV-cachepool op de prefill-GPU's bevatte 681.280 tokens.
Die discrepantie is belangrijk omdat de poolgrootte geen configuratiewaarde is. SGLang bepaalt de grootte bij het opstarten op basis van het GPU-geheugen dat overblijft na het laden van de modelgewichten. De geadverteerde contextlengte is een aparte configuratiewaarde. Niets in het systeem dwingt de twee om overeen te komen, en niets waarschuwt je wanneer ze uiteenlopen.
Prelancetests slaagden. HTTP-statuscodes waren 200, streaming bodies hadden geen error frames, de throughput zag er normaal uit. De tests valideerden transport, niet inhoud.
Probleem twee: een afgewezen verzoek dat zichzelf toch als actief rapporteerde
Wanneer een prompt de pool overschreed, wees de scheduler van SGLang hem af. Het afwijzingspad registreerde de fout voor de client en verving de prompt door een placeholder van één token, zodat het verzoek door de batchmachinerie kon afvoeren zonder geheugen toe te wijzen. Op een enkele node werkte dat prima: de placeholder draaide, de batch was klaar, de client kreeg een fout.
In disaggregated prefill/decode-modus — waar prefill-GPU's de KV-cache opbouwen en via RDMA naar decode-GPU's sturen die tokens genereren — verliep de sequentie als volgt:
- Bootstrap. De router meldde decode dat de prompt 8 pagina's lang was. Decode reserveerde 8 KV-pagina's.
- Prefill-toelating. De wachtrij liet elk verzoek toe waarvoor
req.finished()False retourneerde. Een afgewezen verzoek sloeg zijn afwijzing op into_finish, niet infinished_reason, dusfinished()retourneerde nog steeds False. Het afgewezen verzoek werd toegelaten. - Prefill-forward pass. De prompt was al vervangen door een placeholder van één token. Prefill draaide een triviale forward pass op dat ene token.
- KV-overdracht. Prefill stuurde één pagina KV naar decode en gaf het signaal 'overdracht voltooid'.
- Decode-toelating. De wachtrij van decode gebruikte dezelfde
finished()-check en accepteerde de overdracht. - Decode-attention. Decode genereerde tokens terwijl het attention uitvoerde over alle 8 gereserveerde pagina's. Alleen pagina 0 was door dit verzoek geschreven. Pagina's 1 tot en met 7 bevatten nog prompts en gedeeltelijke generaties van andere tenants.
- Antwoord. De client kreeg HTTP 200, een overeenkomende
prompt_tokens-telling en een coherent antwoord dat was samengesteld uit verouderde cachepagina's.
Elk verzoek boven de drempel van 681.280 tokens gedroeg zich zo. De bug was upstream ingediend als issue #30233 op 6 juli 2026, en de getroffen deployment had de fix nog niet overgenomen.
Detecteer het vóór je spelers dat doen
Je kunt je niet door statuscodes naar isolatie testen. Je hebt drie signalen nodig.
Signaal 1: poolgrootte versus contextlengte, uitgelezen uit de draaiende engine
Controleer dit bij het opstarten en in CI, niet vanuit een configbestand. De pool is afgeleid van overgebleven geheugen, dus een wijziging in quantization, tensor-parallelism of CUDA-graph-grootte verschuift hem ongemerkt.
# deploy_gate.py - run before promoting any disaggregated inference config
import json, os, sys, urllib.request
CONTEXT_LEN = 1_048_576 # the model's advertised window
HEADROOM = 1.25 # pool must exceed context, not merely match it
def server_info(base_url: str) -> dict:
with urllib.request.urlopen(f"{base_url}/get_server_info", timeout=10) as r:
return json.load(r)
def kv_pool_tokens(info: dict) -> int:
# SGLang reports the pool it sized at startup; field names vary by version
for key in ("max_total_num_tokens", "kv_pool_tokens", "max_total_tokens"):
if key in info:
return int(info[key])
raise RuntimeError("KV pool size not exposed by this build - fail closed")
def main(prefill_url: str) -> None:
pool = kv_pool_tokens(server_info(prefill_url))
required = int(CONTEXT_LEN * HEADROOM)
print(f"kv_pool={pool} required={required} context={CONTEXT_LEN}")
if pool < required:
print("FAIL: pool smaller than context window; over-length prompts reach the rejection path")
sys.exit(1)
print("OK: pool covers the full context window")
if __name__ == "__main__":
main(os.environ.get("PREFILL_URL", "http://localhost:30000"))
Draai dit op elke prefill-node voordat de load balancer verkeer ziet. Een exitcode ongelijk aan nul blokkeert de deploy.
Signaal 2: canary-markers in de KV-cache
Zet een unieke sentinel-string in een lange prompt, laat het verzoek afronden en stuur daarna een tweede lange prompt die deze nooit zou mogen bevatten. Controleer de teruggegeven tekst, niet de statuscode.
import uuid, requests
def canary_check(base_url: str, token: str, long_prompt_tokens: int) -> None:
sentinel = f"CANARY-{uuid.uuid4()}"
filler = "lorem ipsum " * (long_prompt_tokens // 2)
requests.post(base_url, json={
"prompt": f"{sentinel} {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
# A fresh, unrelated request must never surface the previous sentinel
r = requests.post(base_url, json={
"prompt": f"Summarize this: {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
assert sentinel not in r.text, f"KV cache leak: {sentinel} surfaced in a fresh response"
Draai dit tegen de langste prompt die je wilt accepteren, precies op de grens, en op grens + 1 token. Het grensgeval is waar het afwijzingspad afgaat.
Signaal 3: latentie die te mooi is
Een volledige prefill kost meetbare tijd. Als een prompt van 8 pagina's terugkomt in de tijd die een prompt van 1 pagina zou moeten kosten, lees je andermans cache. Log de prefill-duur per promptlengte en alarmeer op de p5 van de lange-context-bucket. Een opvallend snelle lange prompt is een leksignatuur, geen overwinning.
De fix, in volgorde
1. Laat de pool de volledige context bevatten
Cloudflare wijzigde de GLM-5.3-configuratie zodat die overeenkwam met GLM-5.2, waardoor er genoeg GPU-geheugen vrijkwam dat de prefill-pool nu ruwweg 2,04 miljoen tokens bevat — ongeveer twee keer de context van 1.048.576 tokens. Een prompt die binnen het contextvenster past, past nu altijd in de pool, dus het afwijzingspad wordt voor een geldig verzoek nooit bereikt.
2. Maak het afwijzingspad alsnog veilig
Alleen vertrouwen op de poolgrootte is fragiel. Elke wijziging in quantization, tensor-parallelism, CUDA-graph-groottes of een modelversie verschuift de pool op een manier die gemakkelijk te missen is.
De engine-patch laat een verzoek met een lopende afwijzing aflopen bij de toelatingsqueue van de disaggregatie, met de oorspronkelijke fout, vóórdat er bootstrap, allocatie of KV-overdracht plaatsvindt. De decode-node verifieert ook dat het aantal overgedragen tokens overeenkomt met het aantal dat hij had gereserveerd. Dat werd upstream opgeleverd als PR #38704, met follow-ups in #38935, en werd uitgebracht in SGLang v0.5.20 tot en met v0.5.22.
Als je ergens SGLang draait, pin dan op v0.5.22 of later. Dit is geen upgrade van 'plannen we in de volgende sprint'.
3. Verifieer met de repro, niet met vibes
Cloudflare valideerde beide wijzigingen met het reproductiescript van de onderzoeker en met hun eigen canary-markertests voordat ze het model herstelden. Twee onafhankelijke tests, één adversarial en één intern. Dat is de juiste lat voordat je een model of een fleet weer inschakelt.
Waarom 'er kwam 200 terug' geen test is
De kern van de testfout was smal en volkomen gewoon: het team valideerde HTTP-responscodes en engineprestaties bij lange contextlengtes, zonder de modeloutput en coherentie te valideren. Benchmarking slaagde omdat benchmarks vragen 'heeft het geantwoord?' en niet 'heeft het correct geantwoord?'.
Voor een gehoste AI-feature is de correctheidscheck goedkoop. Voor je eigen game-backend is het equivalente gat meestal groter. Hier is de overdraagbare regel: elke gedeelde resourcepool heeft een assert nodig die de poolgrootte koppelt aan de grootte van het grootste verzoek dat het systeem accepteert, en die assert moet tegen het live proces draaien, niet tegen een configbestand.
Dezelfde bugklasse leeft in game-backends
Vervang 'KV-cachepagina's' door 'sessieslots' en het incident reproduceert bijna regel voor regel.
Je alloceert een vaste pool van matchslots, spelersstatusslots of actorbuffers. Een verzoek reserveert 8 slots. Een toelatingscheck test state != Finished in plaats van state == Valid, dus een afgewezen reservering wordt alsnog toegelaten. Het slot wordt aan de volgende lobby gegeven met alleen slot 0 geïnitialiseerd. Slots 1 tot en met 7 bevatten nog de speler-ID's, inventaris en locatiegegevens van de vorige match.
Dit is dezelfde faalmodus die we behandelden in game-backends ontwerpen die compromittering overleven: het lek hoeft geen geavanceerde aanval te zijn, het heeft alleen een eigenaarsmodel nodig dat de code niet daadwerkelijk afdwingt.
Hier is het patroon met de bug en de fix, in C#:
public sealed class SessionSlotPool
{
private readonly SessionSlot[] _slots;
private readonly Queue<int> _free = new();
// BUG: release without invalidation leaves stale state for the next tenant
public void ReleaseUnsafe(int[] indices)
{
foreach (var i in indices) _free.Enqueue(i);
}
// FIX: invalidate on release, stamp a generation, verify on read
public void Release(int[] indices)
{
foreach (var i in indices)
{
_slots[i].Reset(); // zero player list, inventory, match id
_slots[i].OwnerId = null;
_slots[i].Generation++; // stale handles now fail the check
_free.Enqueue(i);
}
}
public bool TryRead(int index, long expectedGeneration, out SessionSlot slot)
{
slot = _slots[index];
return slot.Generation == expectedGeneration && slot.OwnerId != null;
}
}
De generatiecounter is het goedkope deel. Het verandert 'we hopen dat het slot is overschreven' in een harde, testbare afwijzing. Elke read op een verouderde handle faalt in plaats van stilletjes andermans gegevens terug te geven.
De isolatie-checklist
- Assert poolgrootte tegen contextlengte bij het opstarten, vanuit het draaiende proces. Niet vanuit config. Een exitcode ongelijk aan nul blokkeert de deploy.
- Behandel 'afgewezen' als een eindtoestand die verschilt van 'afgerond'. Breek af bij toelating, vóór enige allocatie of overdracht, en retourneer de oorspronkelijke fout.
- Verifieer overdrachtstellingen aan beide kanten. Als decode 8 pagina's reserveerde, moet het bevestigen dat er 8 pagina's zijn aangekomen. Een overdracht van één pagina die 'voltooid' meldt, is exact de bug.
- Test het faalpad met inhoudsasserties. Canary-markers, sentinel-strings en prompts op grens + 1. Check de responsebody, nooit de statuscode.
- Voer de gate opnieuw uit bij elke capaciteitswijziging. Quantization, tensor-parallelism, batchgrootte en modelversie verschuiven allemaal de pool. Behandel elk als een isolatierelevante wijziging.
Item 5 is degene die teams overslaan. Elke capaciteitswijziging is ook een beveiligingswijziging, omdat de pool is afgeleid van overgebleven geheugen in plaats van gedeclareerd. Hetzelfde zero-sum-denken geldt voor elke resourcepool die je met de hand beheert — de afweging in de analyse van zero-waste serverhibernation is dezelfde rekenkunde.
Wat dit kost
Isolatie is niet gratis, en de afweging is eerlijk.
Het verdubbelen van de KV-pool van 681.280 naar ruwweg 2,04 miljoen tokens verbruikt GPU-geheugen dat anders meer gelijktijdige verzoeken zou bevatten. Minder gelijktijdige verzoeken betekent hogere kosten per gegenereerd token. Op een prefill-fleet is die headroom capaciteit die je niet kunt verkopen.
Er is een goedkoper alternatief: houd de kleine pool en wijs te lange prompts af met een expliciete HTTP 400 aan de edge, voordat ze de scheduler bereiken. Dat behoudt de throughput, maar laat legitieme lange-contextverzoeken falen — en voor een agentic coding model is dat het grootste deel van het product.
Cloudflare koos voor correctheid ten koste van capaciteit. Voor een spelersgerichte feature is dat bijna altijd de juiste keuze. Het alternatief — een snel, goedkoop, plausibel antwoord met andermans gegevens — is geen afweging die je in een postmortem kunt verdedigen.
Waar de isolatielast eigenlijk ligt
Elk multi-tenant-systeem neemt dit besluit: welke gedeelde resources je poolt en hergebruikt, en wie eigenaar is van de invalidatie. Het antwoord voor GPU-KV-pagina's is 'de inference-engine, slecht, tot v0.5.22'.
Voor de spelersstatus in je game is het antwoord meestal 'je database, en het ligt bij jou.' Als je de pool-semantiek liever helemaal niet beheert, verleggen accountgebonden statusservices het aanvalsoppervlak: horizOn biedt accountgebonden Cloud Save met revisiebewuste reads en writes, waarbij de client conflicten oplost in plaats van dat jouw servers raden welk slot verouderd is.
Dat elimineert isolatiewerk niet — niets doet dat. Het beperkt het tot de grenzen die je daadwerkelijk controleert.
Wat je deze week moet doen
Audit elke gedeelde pool die je bezit en stel drie vragen. Dekt de poolgrootte aantoonbaar het grootste verzoek dat het systeem accepteert? Beëindigt een afgewezen verzoek voordat het iets alloceert? Verifieert de ontvangende kant dat wat is aangekomen overeenkomt met wat is gereserveerd?
Als een antwoord 'we nemen aan van wel' is, heb je dezelfde bug liggen wachten op een lang genoeg verzoek. Begin met de pool waarvan de inhoud het schadelijkst zou zijn in andermans antwoord.
Als je klaar bent om te stoppen met het onderhouden van die pool voor spelersstatus, probeer dan horizOn gratis — authenticatie, accountgebonden Cloud Save, leaderboards, remote config en crashrapporten, zonder vanaf nul een database en een sessiemodel op te tuigen. De API-documentatie laat het integratieoppervlak zien in ongeveer tien minuten.
Bron: Cross-request KV-cacheblootstelling in het GLM-5.3 Workers AI-model, en onze remediëring met SGLang