Cloudflare Workers AI Security Postmortem: Ein Runbook für Cross-Tenant-KV-Cache-Leaks
Kurz und knapp
Erfahren Sie, wie Sie Cross-Tenant-KV-Cache-Leaks in KI-Features erkennen, beheben und künftig verhindern – mit Runbook, Code und Checkliste.
Eine spielerorientierte KI-Funktion gab eine Antwort zurück, die den Prompt-Text eines anderen Studios enthielt. Kein Crash. Kein 500er. Eine kohärente, thematisch passende Antwort, ausgeliefert mit HTTP 200 und einer Token-Anzahl, die der eigenen Anfrage des Aufrufers entsprach.
Das ist die schlimmste Form, die ein Multi-Tenant-Bug annehmen kann: Die Ausgabe ist plausibel, also meldet niemand ein Ticket. Genau das passierte Cloudflare am 27. August 2026, als 155 Antworten des GLM-5.3-Workers-AI-Modells Daten aus Sitzungen anderer Kunden enthielten. Jede betroffene Anfrage war länger als 681.280 Token. Die Behebung erforderte zwei Änderungen – eine Konfigurationsänderung, einen Engine-Patch – und die Erkennungsgeschichte ist lehrreicher als der Fix.
Hier ist das Runbook: Was kaputtging, wie du dieselbe Fehlerklasse in deinem Stack erkennst, wie du sie behebst und wie du verhinderst, dass sie wieder auftritt.
Was tatsächlich kaputtging
Zwei unabhängige Probleme überlagerten sich. Jedes für sich allein wäre verkraftbar gewesen.
Problem eins: ein Kapazitäts-Mismatch, das niemand per Assertion absicherte
Cloudflare stellte GLM-5.3 mit einem Kontextfenster von 1.048.576 Token bereit. Der KV-Cache-Pool auf den Prefill-GPUs umfasste 681.280 Token.
Diese Lücke ist relevant, weil die Poolgröße kein Konfigurationswert ist. SGLang dimensioniert sie beim Start aus dem GPU-Speicher, der nach dem Laden der Modellgewichte übrig bleibt. Die beworbene Kontextlänge ist ein separater Konfigurationswert. Nichts im System erzwingt, dass beide übereinstimmen, und nichts warnt dich, wenn sie auseinanderdriften.
Die Tests vor dem Start liefen durch. HTTP-Statuscodes waren 200, Streaming-Bodies enthielten keine Fehler-Frames, der Durchsatz sah normal aus. Die Tests validierten den Transport, nicht den Inhalt.
Problem zwei: eine abgelehnte Anfrage, die sich weiterhin als aktiv meldete
Wenn ein Prompt den Pool überschritt, lehnte SGLangs Scheduler ihn ab. Der Ablehnungspfad protokollierte den Fehler für den Client und ersetzte den Prompt durch einen Ein-Token-Platzhalter, damit die Anfrage ohne Speicherzuweisung durch die Batch-Mechanik laufen konnte. Auf einem einzelnen Knoten funktionierte das einwandfrei: Der Platzhalter lief, der Batch endete, der Client erhielt einen Fehler.
Im disaggregierten Prefill/Decode-Modus – bei dem Prefill-GPUs den KV-Cache aufbauen und ihn per RDMA an Decode-GPUs übertragen, die Token erzeugen – sah die Abfolge so aus:
- Bootstrap. Der Router teilte Decode mit, dass der Prompt 8 Seiten lang sei. Decode reservierte 8 KV-Seiten.
- Prefill-Admission. Die Warteschlange ließ jede Anfrage zu, bei der
req.finished()False zurückgab. Eine abgelehnte Anfrage speicherte ihre Ablehnung into_finish, nicht infinished_reason, also gabfinished()weiterhin False zurück. Die abgelehnte Anfrage wurde zugelassen. - Prefill-Forward-Pass. Der Prompt war bereits durch einen Ein-Token-Platzhalter ersetzt worden. Prefill führte einen trivialen Forward-Pass auf diesem einzelnen Token aus.
- KV-Transfer. Prefill sendete eine Seite KV an Decode und signalisierte „Transfer abgeschlossen".
- Decode-Admission. Decodes Warteschlange verwendete denselben
finished()-Check und akzeptierte die Übergabe. - Decode-Attention. Decode erzeugte Token, während die Attention über alle 8 reservierten Seiten lief. Nur Seite 0 war von dieser Anfrage beschrieben worden. Die Seiten 1 bis 7 enthielten noch Prompts und partielle Generierungen anderer Tenants.
- Antwort. Der Client erhielt HTTP 200, eine passende
prompt_tokens-Anzahl und eine kohärente Antwort, die aus veralteten Cache-Seiten zusammengesetzt war.
Jede Anfrage über der 681.280-Token-Schwelle verhielt sich so. Der Bug war upstream als Issue #30233 am 6. Juli 2026 gemeldet worden, und das betroffene Deployment hatte den Fix noch nicht übernommen.
Erkenne es, bevor deine Spieler es tun
Mit Statuscodes allein kannst du dich nicht bis zur Isolation testen. Du brauchst drei Signale.
Signal 1: Poolgröße versus Kontextlänge, aus der laufenden Engine gelesen
Prüfe das beim Start und in CI, nicht aus einer Konfigurationsdatei. Der Pool wird aus dem übrig gebliebenen Speicher abgeleitet, daher verschiebt eine Änderung der Quantisierung, der Tensor-Parallelität oder eine Anpassung der CUDA-Graphgröße ihn stillschweigend.
# 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"))
Führe es gegen jeden Prefill-Knoten aus, bevor der Load Balancer Traffic sieht. Ein Exit-Code ungleich null blockiert das Deployment.
Signal 2: Canary-Marker im KV-Cache
Platziere eine eindeutige Sentinel-Zeichenkette in einem langen Prompt, lass die Anfrage abschließen und sende dann einen zweiten langen Prompt, der sie niemals enthalten darf. Prüfe den zurückgegebenen Text, nicht den 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"
Führe das gegen den längsten Prompt aus, den du akzeptieren willst, exakt an der Grenze und bei Grenze + 1 Token. Der Grenzfall ist der, bei dem der Ablehnungspfad greift.
Signal 3: Latenz, die zu gut ist
Ein vollständiger Prefill dauert messbar. Wenn ein 8-Seiten-Prompt in der Zeit zurückkommt, die ein 1-Seiten-Prompt benötigen sollte, liest du den Cache von jemand anderem. Protokolliere die Prefill-Dauer, indexiert nach Promptlänge, und alarmiere beim p5 des Long-Context-Buckets. Ein ungewöhnlich schneller langer Prompt ist eine Leak-Signatur, kein Erfolg.
Der Fix, in der richtigen Reihenfolge
1. Sorge dafür, dass der Pool das gesamte Kontextfenster fasst
Cloudflare änderte die GLM-5.3-Konfiguration so, dass sie der von GLM-5.2 entspricht, wodurch genug GPU-Speicher freiwurde, sodass der Prefill-Pool nun grob 2,04 Millionen Token fasst – etwa doppelt so viel wie das 1.048.576-Token-Kontextfenster. Ein Prompt, der ins Kontextfenster passt, passt jetzt auch immer in den Pool, sodass der Ablehnungspfad für eine gültige Anfrage nie erreicht wird.
2. Mach den Ablehnungspfad trotzdem sicher
Pool-Dimensionierung allein ist fragil. Jede Änderung an Quantisierung, Tensor-Parallelität, CUDA-Graphgrößen oder eine Modellversion verschiebt den Pool auf eine Weise, die leicht übersehen wird.
Der Engine-Patch entfernt eine Anfrage mit ausstehender Ablehnung bereits in der Disaggregation-Admission-Queue, mit ihrem ursprünglichen Fehler, bevor irgendein Bootstrap, eine Allokation oder ein KV-Transfer stattfindet. Der Decode-Knoten prüft außerdem, dass die Anzahl der übertragenen Token der Anzahl entspricht, die er reserviert hat. Das ging upstream als PR #38704 ein, mit Folge-PRs in #38935, und wurde in SGLang v0.5.20 bis v0.5.22 ausgeliefert.
Wenn du irgendwo SGLang betreibst, pinne auf v0.5.22 oder neuer. Das ist kein Upgrade, das du auf den nächsten Sprint verschieben kannst.
3. Verifiziere mit dem Repro, nicht mit Bauchgefühl
Cloudflare validierte beide Änderungen gegen das Reproduktionsskript des Forschers und die eigenen Canary-Marker-Tests, bevor das Modell wiederhergestellt wurde. Zwei unabhängige Tests, einer adversarisch, einer intern. Das ist die richtige Messlatte, bevor du ein Modell oder eine Flotte wieder aktivierst.
Warum „Es gab 200 zurück" kein Test ist
Der eigentliche Testfehler war eng umrissen und völlig gewöhnlich: Das Team validierte HTTP-Statuscodes und Engine-Leistung bei langen Kontextlängen, ohne Modellausgabe und Kohärenz zu validieren. Das Benchmarking lief durch, weil Benchmarks fragen „Hat es geantwortet?" und nicht „Hat es richtig geantwortet?"
Für ein gehostetes KI-Feature ist der Korrektheitscheck günstig. Für dein eigenes Game-Backend ist die äquivalente Lücke meist größer. Hier ist die übertragbare Regel: Jeder gemeinsame Ressourcenpool braucht eine Assertion, die die Größe des Pools an die Größe der größten Anfrage koppelt, die das System akzeptiert, und diese Assertion muss gegen den laufenden Prozess laufen, nicht gegen eine Konfigurationsdatei.
Dieselbe Fehlerklasse steckt auch in Game-Backends
Ersetze „KV-Cache-Seiten" durch „Session-Slots", und der Vorfall reproduziert sich fast Zeile für Zeile.
Du allokierst einen festen Pool aus Match-Slots, Player-State-Slots oder Actor-Buffern. Eine Anfrage reserviert 8 Slots. Ein Admission-Check testet state != Finished statt state == Valid, also wird eine abgelehnte Reservierung trotzdem zugelassen. Der Slot wird an die nächste Lobby übergeben, wobei nur Slot 0 initialisiert ist. Die Slots 1 bis 7 enthalten noch die Player-IDs, Inventar und Positionsdaten des vorherigen Matches.
Das ist dieselbe Fehlerart, die wir in Game-Backends so architektieren, dass sie Kompromittierungen überstehen behandelt haben: Der Leak muss kein ausgefeilter Angriff sein, er braucht nur ein Ownership-Modell, das der Code nicht tatsächlich durchsetzt.
Hier ist das fehlerhafte Muster und der 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;
}
}
Der Generationszähler ist der günstige Teil. Er verwandelt „wir hoffen, der Slot wurde überschrieben" in eine harte, testbare Ablehnung. Jeder Lesezugriff über einen veralteten Handle schlägt fehl, statt stillschweigend die Daten eines anderen Spielers zurückzugeben.
Die Isolation-Checkliste
- Prüfe die Poolgröße gegen die Kontextlänge beim Start, aus dem laufenden Prozess. Nicht aus der Config. Ein Exit-Code ungleich null blockiert das Deployment.
- Behandle „rejected" als terminalen Zustand, getrennt von „finished". Brich bei der Admission ab, vor jeder Allokation oder Übergabe, und gib den ursprünglichen Fehler zurück.
- Verifiziere Transferzahlen auf beiden Seiten. Wenn Decode 8 Seiten reserviert hat, muss es bestätigen, dass 8 Seiten angekommen sind. Ein Ein-Seiten-Transfer, der „complete" meldet, ist genau der Bug.
- Teste den Fehlerpfad mit Inhalts-Assertions. Canary-Marker, Sentinel-Strings, Grenze-plus-eins-Prompts. Prüfe den Response-Body, niemals den Statuscode.
- Führe das Gate bei jeder Kapazitätsänderung erneut aus. Quantisierung, Tensor-Parallelität, Batchgröße und Modellversion verschieben den Pool. Behandle jede davon als isolationsrelevante Änderung.
Punkt 5 ist der, den Teams überspringen. Jede Kapazitätsänderung ist auch eine Sicherheitsänderung, weil der Pool aus übrig gebliebenem Speicher abgeleitet wird, statt deklariert zu sein. Dasselbe Nullsummen-Denken gilt für jeden Ressourcenpool, den du von Hand dimensionierst – die Trade-off-Mathematik in der Zero-Waste-Server-Hibernation-Analyse ist dieselbe Arithmetik.
Was das kostet
Isolation ist nicht gratis, und der Trade-off ist ehrlich.
Den KV-Pool von 681.280 auf grob 2,04 Millionen Token zu verdoppeln, verbraucht GPU-Speicher, der sonst mehr gleichzeitige Anfragen aufnehmen würde. Weniger gleichzeitige Anfragen bedeuten höhere Kosten pro erzeugtem Token. Auf einer Prefill-Flotte ist dieser Headroom Kapazität, die du nicht verkaufen kannst.
Es gibt eine günstigere Alternative: Den kleinen Pool behalten und überlange Prompts mit einem expliziten HTTP 400 am Edge ablehnen, bevor sie den Scheduler erreichen. Das erhält den Durchsatz, lässt aber legitime Long-Context-Anfragen scheitern – was bei einem agentischen Coding-Modell den Großteil des Produkts ausmacht.
Cloudflare entschied sich für Korrektheit zu Lasten der Kapazität. Bei einem spielerorientierten Feature ist das fast immer richtig. Die Alternative – eine schnelle, billige, plausible Antwort mit den Daten eines anderen Spielers – ist kein Trade-off, den du in einem Postmortem verteidigen kannst.
Wo die Isolationslast tatsächlich sitzt
Jedes Multi-Tenant-System trifft diese Entscheidung: Welche gemeinsamen Ressourcen poolst und wiederverwendest du, und wer besitzt die Invalidierung? Die Antwort für GPU-KV-Seiten lautet „die Inference-Engine, schlecht, bis v0.5.22".
Für den Player-State deines Spiels lautet die Antwort meist „deine Datenbank, und es liegt an dir". Wenn du die Pool-Semantik lieber gar nicht besitzen willst, verschieben accountgebundene State-Services die Angriffsfläche: horizOn bietet accountgebundenes Cloud Save mit revisionsbewussten Lese- und Schreibzugriffen, bei denen der Client Konflikte auflöst, statt dass deine Server raten, welcher Slot veraltet ist.
Das beseitigt die Isolationsarbeit nicht – nichts tut das. Es verengt sie auf die Grenzen, die du tatsächlich kontrollierst.
Was du diese Woche tun solltest
Prüfe jeden gemeinsamen Pool, den du besitzt, und stelle drei Fragen. Deckt die Poolgröße nachweislich die größte Anfrage ab, die das System akzeptiert? Beendet sich eine abgelehnte Anfrage, bevor sie irgendetwas allokiert? Verifiziert die empfangende Seite, dass das, was angekommen ist, dem entspricht, was sie reserviert hat?
Wenn irgendeine Antwort „wir gehen davon aus" lautet, hast du denselben Bug, der nur auf eine ausreichend lange Anfrage wartet. Beginne mit dem Pool, dessen Inhalte in der Antwort eines anderen Spielers am schädlichsten wären.
Wenn du bereit bist, diesen Pool für Player-State nicht mehr selbst zu pflegen, teste horizOn kostenlos – Authentifizierung, accountgebundenes Cloud Save, Leaderboards, Remote Config und Crash Reports, ohne eine Datenbank und ein Session-Modell von Grund auf aufzuziehen. Die API-Dokumentation zeigt die Integrationsfläche in etwa zehn Minuten.
Quelle: Cross-Request-KV-Cache-Exposition im GLM-5.3-Workers-AI-Modell und unsere Behebung mit SGLang