Cloudflare Workers AI Security Postmortem: Runbook dla wycieków KV cache między tenantami
W skrócie
Poznaj runbook po wycieku KV cache między tenantami w Cloudflare Workers AI i zabezpiecz backend własnej gry przed tą samą klasą błędów w produkcji.
Funkcja AI działająca po stronie gracza zwróciła odpowiedź zawierającą tekst promptu innego studia. To nie był crash. Nie 500. Spójna, trafna odpowiedź dostarczona z HTTP 200 i liczbą tokenów zgodną z żądaniem wywołującego.
To najgorsza postać, jaką może przyjąć bug w systemie multi-tenant: wynik jest wiarygodny, więc nikt nie tworzy ticketa. Cloudflare trafił dokładnie na to 27 sierpnia 2026, gdy 155 odpowiedzi z modelu GLM-5.3 Workers AI zawierało dane z sesji innych klientów. Każde dotknięte żądanie było dłuższe niż 681 280 tokenów. Poprawka wymagała dwóch zmian — jednej w konfiguracji i jednego patcha w silniku — a historia wykrywania jest bardziej pouczająca niż sama poprawka.
Oto runbook: co się zepsuło, jak wykryć tę samą klasę awarii w twoim stacku, jak ją naprawić i jak zapobiec jej nawrotom.
Co właściwie się zepsuło
Dwa niezależne problemy nałożyły się na siebie. Każdy z osobna byłby do przeżycia.
Problem pierwszy: niedopasowanie pojemności, na które nikt nie postawił asercji
Cloudflare wdrożył GLM-5.3 z oknem kontekstowym o długości 1 048 576 tokenów. Pula KV cache na GPU prefilla trzymała 681 280 tokenów.
Ta luka ma znaczenie, ponieważ rozmiar puli nie jest wartością konfiguracyjną. SGLang ustala ją przy starcie z tej pamięci GPU, która zostaje po załadowaniu wag modelu. Reklamowana długość kontekstu to osobna wartość konfiguracyjna. Nic w systemie nie wymusza ich zgodności i nic nie ostrzega, gdy się rozjadą.
Testy przedpremierowe przeszły. Kody HTTP to było 200, w strumieniowanych odpowiedziach nie było ramek błędów, przepustowość wyglądała normalnie. Testy walidowały transport, nie treść.
Problem drugi: odrzucone żądanie, które wciąż raportowało siebie jako żywe
Gdy prompt przekraczał pulę, scheduler SGLang go odrzucał. Ścieżka odrzucenia zapisywała błąd dla klienta i podmieniała prompt na placeholder o długości jednego tokena, żeby żądanie mogło zostać opróżnione przez mechanikę batcha bez alokowania pamięci. Na pojedynczym nodze działało to poprawnie: placeholder się wykonał, batch się skończył, klient dostał błąd.
W rozdzielonym trybie prefill/decode — gdzie GPU prefilla budują KV cache i przesyłają go przez RDMA do GPU decode generujących tokeny — sekwencja wyglądała następująco:
- Bootstrap. Router poinformował decode, że prompt ma 8 stron. Decode zarezerwował 8 stron KV.
- Przyjęcie przez prefill. Kolejka przyjmowała każde żądanie, dla którego
req.finished()zwracało False. Odrzucone żądanie zapisywało odrzucenie wto_finish, a nie wfinished_reason, więcfinished()nadal zwracało False. Odrzucone żądanie zostało przyjęte. - Forward pass w prefill. Prompt został już podmieniony na placeholder jednego tokena. Prefill wykonał trywialny forward pass na tym pojedynczym tokenie.
- Transfer KV. Prefill wysłał jedną stronę KV do decode i zasygnalizował „transfer complete”.
- Przyjęcie przez decode. Kolejka decode używała tego samego sprawdzenia
finished()i zaakceptowała przekazanie. - Uwaga (attention) w decode. Decode generował tokeny, wykonując attention na wszystkich 8 zarezerwowanych stronach. Tylko strona 0 została zapisana przez to żądanie. Strony 1–7 wciąż trzymały prompty i częściowe generacje innych tenantów.
- Odpowiedź. Klient dostał HTTP 200, liczbę
prompt_tokenszgodną z żądaniem i spójną odpowiedź złożoną ze starych stron cache.
Każde żądanie powyżej progu 681 280 tokenów zachowywało się w ten sposób. Bug został wcześniej zgłoszony upstream jako issue #30233 6 lipca 2026 roku, a dotknięte wdrożenie nie zawierało jeszcze tej poprawki.
Wykryj to, zanim zrobią to twoi gracze
Nie osiągniesz izolacji testami opartymi na kodach statusu. Potrzebujesz trzech sygnałów.
Sygnał 1: rozmiar puli a długość kontekstu — odczytane z działającego silnika
Sprawdzaj to przy starcie i w CI, nie z pliku konfiguracyjnego. Pula wynika z pozostałej pamięci, więc zmiana kwantyzacji, zmiana tensor-parallelism lub zmiana rozmiaru CUDA graph po cichu ją przesuwa.
# 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"))
Uruchom go na każdym nodzie prefilla, zanim ruch trafi na load balancer. Niezerowy kod wyjścia blokuje wdrożenie.
Sygnał 2: markery kanarkowe w KV cache
Umieść unikalny sentinel w długim prompcie, pozwól żądaniu się zakończyć, a potem wyślij drugi długi prompt, który nigdy nie powinien go zawierać. Sprawdzaj zwrócony tekst, nie kod statusu.
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"
Uruchom to dla najdłuższego promptu, jaki zamierzasz akceptować, dokładnie na granicy oraz dla granicy + 1 token. Przypadek graniczny to ten, w którym odpala się ścieżka odrzucenia.
Sygnał 3: zbyt dobra latencja
Pełnowymiarowy prefill zajmuje mierzalny czas. Jeśli prompt o 8 stronach zwraca się w czasie, w jakim powinien zwrócić się prompt o 1 stronie, czytasz czyjś cache. Loguj czas prefilla indeksowany długością promptu i alertuj na p5 w kubełku długiego kontekstu. Podejrzanie szybki długi prompt to sygnatura wycieku, nie sukces.
Poprawka, w kolejności
1. Niech pula obejmie pełny kontekst
Cloudflare zmienił konfigurację GLM-5.3 na zgodną z GLM-5.2, uwalniając tyle pamięci GPU, że pula prefilla mieści teraz około 2,04 miliona tokenów — mniej więcej dwa razy więcej niż kontekst 1 048 576 tokenów. Prompt, który mieści się w oknie kontekstu, zawsze mieści się w puli, więc ścieżka odrzucenia nie jest osiągana dla prawidłowego żądania.
2. I tak zabezpiecz ścieżkę odrzucenia
Sam rozmiar puli to krucha ochrona. Każda zmiana kwantyzacji, tensor parallelism, rozmiarów CUDA graph czy wersji modelu przesuwa pulę w sposób, który łatwo przeoczyć.
Patch w silniku wycofuje żądanie z oczekującym odrzuceniem już w kolejce przyjęć, z oryginalnym błędem, zanim nastąpi jakikolwiek bootstrap, alokacja czy transfer KV. Node decode weryfikuje również, czy liczba przesłanych tokenów zgadza się z liczbą zarezerwowaną. Trafiło to do upstream jako PR #38704, z poprawkami w #38935, i zostało wydane w SGLang od v0.5.20 do v0.5.22.
Jeśli gdziekolwiek używasz SGLang, przypnij wersję do v0.5.22 lub nowszej. To nie jest uaktualnienie typu „wrzucimy to na następny sprint”.
3. Weryfikuj repro, nie przeczuciem
Cloudflare zweryfikował obie zmiany na skrypcie reprodukcyjnym badacza i własnych testach kanarkowych przed przywróceniem modelu. Dwa niezależne testy — jeden adwersarialny, jeden wewnętrzny. To właściwa poprzeczka, zanim ponownie włączysz model lub flotę.
Dlaczego „zwróciło 200” to nie jest test
Źródło porażki testowej było wąskie i całkowicie zwyczajne: zespół walidował kody odpowiedzi HTTP i wydajność silnika przy długich kontekstach, bez walidowania outputu modelu i jego spójności. Benchmarki przeszły, bo pytają „czy odpowiedział?”, a nie „czy odpowiedział poprawnie?”.
Dla hostowanej funkcji AI sprawdzenie poprawności jest tanie. Dla twojego własnego backendu gry analogiczna luka jest zwykle szersza. Oto reguła, którą można przenieść: każda współdzielona pula zasobów potrzebuje asercji wiążącej rozmiar puli z rozmiarem największego żądania, jakie system zaakceptuje, i asercja ta musi działać na żywym procesie, a nie na pliku konfiguracyjnym.
Ta sama klasa bugów żyje w backendach gier
Podmień „KV cache pages” na „session slots”, a ten incydent odtworzy się niemal linijka po linijce.
Alokujesz stałą pulę slotów meczów, slotów stanu gracza lub buforów aktorów. Żądanie rezerwuje 8 slotów. Sprawdzenie przyjęcia testuje state != Finished zamiast state == Valid, więc odrzucona rezerwacja i tak zostaje przyjęta. Slot trafia do następnego lobby z zainicjalizowanym tylko slotem 0. Sloty 1–7 wciąż trzymają ID graczy, ekwipunek i pozycje z poprzedniego meczu.
To ten sam tryb awarii, który omówiliśmy w architekturze backendów gier odpornych na kompromitację: wyciek nie musi być wyrafinowanym atakiem, wystarczy model własności, którego kod w rzeczywistości nie egzekwuje.
Oto błędny wzorzec i poprawka w 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;
}
}
Licznik generacji to tania część. Zamienia „mamy nadzieję, że slot został nadpisany” w twarde, testowalne odrzucenie. Każdy odczyt przez nieaktualny uchwyt kończy się błędem, zamiast po cichu zwracać dane innego gracza.
Lista kontrolna izolacji
- Asertywnie sprawdzaj rozmiar puli względem długości kontekstu przy starcie, z działającego procesu. Nie z pliku konfiguracyjnego. Niezerowy kod wyjścia blokuje wdrożenie.
- Traktuj „odrzucone” jako stan terminalny, odrębny od „zakończone”. Przerywaj przy przyjęciu, przed jakąkolwiek alokacją lub przekazaniem, i zwracaj oryginalny błąd.
- Weryfikuj liczniki transferu po obu stronach. Jeśli decode zarezerwował 8 stron, musi potwierdzić, że dotarło 8 stron. Transfer jednej strony raportowany jako „complete” to dokładnie ten bug.
- Testuj ścieżkę awarii asercjami treści. Markery kanarkowe, stringi sentinel, prompty na granicy plus jeden token. Sprawdzaj body odpowiedzi, nigdy kod statusu.
- Uruchamiaj ponownie bramkę przy każdej zmianie pojemności. Kwantyzacja, tensor parallelism, rozmiar batcha i wersja modelu — wszystko przesuwa pulę. Traktuj każdą z tych zmian jako istotną dla izolacji.
Punkt 5 to ten, który zespoły pomijają. Każda zmiana pojemności jest też zmianą bezpieczeństwa, bo pula wynika z pozostałej pamięci, a nie jest deklarowana. Ta sama logika sumy zerowej dotyczy każdej puli zasobów wymiarzanej ręcznie — matematyka kompromisów z analizy serwerów o zerowym marnowaniu to ta sama arytmetyka.
Ile to kosztuje
Izolacja nie jest za darmo i ten kompromis jest uczciwy.
Podwojenie puli KV z 681 280 do około 2,04 miliona tokenów zużywa pamięć GPU, która mogłaby pomieścić więcej równoczesnych żądań. Mniej równoczesnych żądań oznacza wyższy koszt na wygenerowany token. Na flocie prefill ta rezerwa to pojemność, której nie sprzedasz.
Jest tańsza alternatywa: zostaw małą pulę i odrzucaj zbyt długie prompty jawnym HTTP 400 na krawędzi, zanim dotrą do schedulera. To zachowuje przepustowość, ale zawodzi legalne żądania długiego kontekstu — co dla agentowego modelu kodowania jest większością produktu.
Cloudflare wybrał poprawność kosztem pojemności. Dla funkcji widocznej dla graczy to prawie zawsze właściwy wybór. Alternatywa — szybka, tania, wiarygodna odpowiedź zawierająca dane innego gracza — to kompromis, którego nie obronisz w postmortem.
Gdzie naprawdę leży ciężar izolacji
Każdy system multi-tenant podejmuje tę decyzję: które współdzielone zasoby pulujesz i wykorzystujesz ponownie, a kto jest właścicielem unieważniania. Dla stron KV na GPU odpowiedź brzmi „silnik inferencji, źle, do wersji v0.5.22”.
W przypadku stanu gracza w twojej grze odpowiedź brzmi zwykle „twoja baza danych i to na tobie”. Jeśli wolisz w ogóle nie posiadać semantyki puli, usługi stanu przypisanego do konta przesuwają powierzchnię ataku: horizOn oferuje przypisany do konta Cloud Save z odczytami i zapisami uwzględniającymi rewizje, gdzie to klient rozwiązuje konflikty, zamiast twoich serwerów zgadujących, który slot jest nieświeży.
To nie eliminuje pracy nad izolacją — nic tego nie robi. Zawęża ją do granic, które faktycznie kontrolujesz.
Co zrobić w tym tygodniu
Przejrzyj każdą współdzieloną pulę, którą posiadasz, i zadaj trzy pytania. Czy rozmiar puli w sposób możliwy do udowodnienia pokrywa największe żądanie akceptowane przez system? Czy odrzucone żądanie kończy się, zanim cokolwiek zaalokuje? Czy strona odbierająca weryfikuje, że to, co dotarło, zgadza się z tym, co zarezerwowano?
Jeśli którakolwiek odpowiedź to „zakładamy, że tak”, masz ten sam bug czekający na odpowiednio długie żądanie. Zacznij od puli, której zawartość wyrządziłaby największe szkody w odpowiedzi dla innego gracza.
Jeśli jesteś gotów przestać utrzymywać tę pulę dla stanu gracza, wypróbuj horizOn za darmo — uwierzytelnianie, przypisany do konta Cloud Save, tablice wyników, konfigurację zdalną i raporty crashów, bez stawiania od zera bazy danych i modelu sesji. Dokumentacja API pokazuje powierzchnię integracji w około dziesięć minut.
Źródło: Międzyżądaniowa ekspozycja KV cache w modelu GLM-5.3 Workers AI i nasza remediacja w SGLang