Postmortem de Segurança do Cloudflare Workers AI: Um Runbook para Vazamentos de Cache KV Entre Locatários
Em resumo
Descubra como detectar e corrigir vazamentos de cache KV entre locatários com este runbook técnico baseado no incidente do Cloudflare Workers AI.
Um recurso de IA voltado ao jogador retornou uma resposta contendo o texto de prompt de outro estúdio. Não um crash. Não um 500. Uma resposta coerente e dentro do assunto, entregue com HTTP 200 e uma contagem de tokens que correspondia à própria solicitação do chamador.
Esse é o pior formato que um bug multi-tenant pode assumir: a saída é plausível, então ninguém abre um ticket. A Cloudflare passou exatamente por isso em 27 de agosto de 2026, quando 155 respostas do modelo GLM-5.3 Workers AI continham dados de sessões de outros clientes. Todas as solicitações afetadas tinham mais de 681.280 tokens. A correção exigiu duas mudanças — uma de configuração, uma patch no engine — e a história da detecção é mais instrutiva do que a correção.
Aqui está o runbook: o que quebrou, como detectar a mesma classe de falha na sua stack, como remediar e como evitar que se repita.
O Que Realmente Quebrou
Dois problemas independentes se sobrepuseram. Qualquer um deles, isoladamente, teria sido sobrevivível.
Problema um: uma incompatibilidade de capacidade que ninguém validou
A Cloudflare implantou o GLM-5.3 com uma janela de contexto de 1.048.576 tokens. O pool de cache KV nas GPUs de prefill continha 681.280 tokens.
Essa diferença importa porque o tamanho do pool não é um valor de configuração. O SGLang o dimensiona na inicialização a partir da memória GPU que sobra após carregar os pesos do modelo. O tamanho de contexto anunciado é um valor de configuração separado. Nada no sistema força os dois a concordarem, e nada avisa quando eles divergem.
Os testes de pré-lançamento passaram. Os códigos de status HTTP eram 200, os corpos de streaming não tinham frames de erro, a throughput parecia normal. Os testes validavam transporte, não conteúdo.
Problema dois: uma solicitação rejeitada que ainda se reportava como viva
Quando um prompt excedia o pool, o escalonador do SGLang o rejeitava. O caminho de rejeição registrava o erro para o cliente e trocava o prompt por um placeholder de um token para que a solicitação pudesse passar pela maquinaria de batch sem alocar memória. Em um único nó, isso funcionava bem: o placeholder rodava, o batch terminava e o cliente recebia um erro.
No modo prefill/decode desagregado — em que GPUs de prefill constroem o cache KV e o enviam via RDMA para GPUs de decode que geram tokens — a sequência foi esta:
- Bootstrap. O roteador informou ao decode que o prompt tinha 8 páginas. O decode reservou 8 páginas KV.
- Admissão no prefill. A fila admitia qualquer solicitação em que
req.finished()retornasse False. Uma solicitação rejeitada armazenava sua rejeição emto_finish, não emfinished_reason, entãofinished()ainda retornava False. A solicitação rejeitada era admitida. - Forward pass no prefill. O prompt já havia sido substituído por um placeholder de um token. O prefill executou um forward pass trivial nesse único token.
- Transferência de KV. O prefill enviou uma página de KV para o decode e sinalizou "transferência completa".
- Admissão no decode. A fila do decode usava a mesma verificação de
finished()e aceitava o handoff. - Atenção no decode. O decode gerava tokens enquanto atendia a todas as 8 páginas reservadas. Apenas a página 0 tinha sido escrita por esta solicitação. As páginas 1 a 7 ainda continham prompts e gerações parciais de outros locatários.
- Resposta. O cliente recebeu HTTP 200, uma contagem de
prompt_tokenscorrespondente e uma resposta coerente montada a partir de páginas de cache obsoletas.
Toda solicitação acima do limite de 681.280 tokens se comportava dessa forma. O bug havia sido registrado upstream como issue #30233 em 6 de julho de 2026, e a implantação afetada ainda não tinha aplicado a correção.
Detecte Isso Antes dos Seus Jogadores
Você não consegue testar isolamento com códigos de status. Você precisa de três sinais.
Sinal 1: tamanho do pool versus tamanho de contexto, lido do engine em execução
Verifique isso na inicialização e no CI, não a partir de um arquivo de configuração. O pool é derivado da memória restante, então uma mudança de quantização, uma mudança de tensor parallelism ou um ajuste no tamanho dos CUDA graphs move isso silenciosamente.
# 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"))
Rode-o contra cada nó de prefill antes que o load balancer veja tráfego. Uma saída não-zero bloqueia o deploy.
Sinal 2: marcadores canário no cache KV
Plante uma string sentinela única em um prompt longo, deixe a solicitação terminar e envie um segundo prompt longo que nunca deveria contê-la. Verifique o texto retornado, não o código de status.
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"
Rode isso contra o maior prompt que você pretende aceitar, exatamente no limite, e no limite + 1 token. O caso do limite é onde o caminho de rejeição dispara.
Sinal 3: latência boa demais
Um prefill de comprimento total leva um tempo mensurável. Se um prompt de 8 páginas retorna no tempo que um prompt de 1 página levaria, você está lendo o cache de outra pessoa. Registre a duração do prefill indexada pelo comprimento do prompt e alerte no p5 do bucket de contexto longo. Um prompt longo anomalamente rápido é uma assinatura de vazamento, não uma vitória.
A Correção, Em Ordem
1. Faça o pool comportar o contexto completo
A Cloudflare mudou a configuração do GLM-5.3 para igualar a do GLM-5.2, liberando memória GPU suficiente para que o pool de prefill agora comporte aproximadamente 2,04 milhões de tokens — cerca de duas vezes o contexto de 1.048.576 tokens. Um prompt que cabe na janela de contexto agora sempre cabe no pool, então o caminho de rejeição nunca é alcançado para uma solicitação válida.
2. Torne o caminho de rejeição seguro mesmo assim
O dimensionamento do pool por si só é frágil. Qualquer mudança em quantização, tensor parallelism, tamanhos de CUDA graphs ou versão do modelo move o pool de uma forma que é fácil não notar.
A patch no engine encerra uma solicitação com rejeição pendente na fila de admissão da desagregação, com seu erro original, antes que qualquer bootstrap, alocação ou transferência de KV aconteça. O nó de decode também verifica se o número de tokens transferidos corresponde ao número que ele reservou. Isso entrou upstream como PR #38704, com continuações em #38935, e foi lançado no SGLang v0.5.20 até o v0.5.22.
Se você roda SGLang em qualquer lugar, fixe a versão em v0.5.22 ou posterior. Este não é um upgrade para "agendar na próxima sprint".
3. Verifique com o repro, não com achismo
A Cloudflare validou ambas as mudanças com o script de reprodução do pesquisador e com seus próprios testes de marcadores canário antes de restaurar o modelo. Dois testes independentes, um adversarial e um interno. Esse é o padrão correto antes de reativar um modelo ou uma frota.
Por Que "Retornou 200" Não É um Teste
A falha fundamental dos testes era restrita e completamente comum: o time validou códigos de resposta HTTP e a performance do engine em contextos longos, sem validar a saída e a coerência do modelo. Os benchmarks passaram porque perguntam "isso respondeu?" e não "isso respondeu corretamente?"
Para um recurso de IA hospedado, a verificação de correção é barata. Para o seu próprio backend de jogo, a lacuna equivalente costuma ser maior. Aqui está a regra transferível: qualquer pool de recursos compartilhados precisa de uma asserção que amarre o tamanho do pool ao tamanho da maior solicitação que o sistema aceitará, e essa asserção deve rodar contra o processo em execução, não um arquivo de configuração.
A Mesma Classe de Bug Existe Em Backends de Jogos
Substitua "páginas de cache KV" por "slots de sessão" e o incidente se reproduz quase linha por linha.
Você aloca um pool fixo de slots de partida, slots de estado do jogador ou buffers de atores. Uma solicitação reserva 8 slots. Uma verificação de admissão testa state != Finished em vez de state == Valid, então uma reserva rejeitada acaba admitida mesmo assim. O slot é entregue ao próximo lobby com apenas o slot 0 inicializado. Os slots 1 a 7 ainda contêm IDs de jogadores, inventário e dados de posição da partida anterior.
Este é o mesmo modo de falha que cobrimos em arquitetando backends de jogos para sobreviver a comprometimentos: o vazamento não precisa ser um ataque sofisticado, ele só precisa de um modelo de propriedade que o código não aplica de fato.
Aqui está o padrão com bug e a correção, em 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;
}
}
O contador de geração é a parte barata. Ele converte "esperamos que o slot tenha sido sobrescrito" em uma rejeição dura e testável. Qualquer leitura com um handle obsoleto falha em vez de retornar silenciosamente os dados de outro jogador.
A Checklist de Isolamento
- Valide o tamanho do pool contra o tamanho de contexto na inicialização, a partir do processo em execução. Não a partir da config. Uma saída não-zero bloqueia o deploy.
- Trate "rejeitado" como um estado terminal distinto de "finalizado". Aborte na admissão, antes de qualquer alocação ou handoff, e retorne o erro original.
- Verifique as contagens de transferência dos dois lados. Se o decode reservou 8 páginas, ele deve confirmar que 8 páginas chegaram. Uma transferência de uma página reportando "completa" é exatamente o bug.
- Teste o caminho de falha com asserções de conteúdo. Marcadores canário, strings sentinela, prompts no limite + 1 token. Verifique o corpo da resposta, nunca o código de status.
- Rode o gate novamente a cada mudança de capacidade. Quantização, tensor parallelism, tamanho de batch e versão do modelo movem o pool. Trate cada uma como uma mudança relevante para isolamento.
O item 5 é o que os times pulam. Toda mudança de capacidade também é uma mudança de segurança, porque o pool é derivado da memória restante em vez de declarado. O mesmo raciocínio de soma zero se aplica a qualquer pool de recursos dimensionado manualmente — a matemática do trade-off em a análise de hibernação de servidores com desperdício zero é a mesma aritmética.
Quanto Isso Custa
Isolamento não é grátis, e o trade-off é honesto.
Dobrar o pool KV de 681.280 para aproximadamente 2,04 milhões de tokens consome memória GPU que, de outra forma, comportaria mais solicitações concorrentes. Menos solicitações concorrentes significa maior custo por token gerado. Em uma frota de prefill, esse headroom é capacidade que você não pode vender.
Existe uma alternativa mais barata: manter o pool pequeno e rejeitar prompts acima do limite com um HTTP 400 explícito na borda, antes que cheguem ao escalonador. Isso preserva a throughput, mas falha solicitações legítimas de contexto longo — que, para um modelo de codificação agêntico, são a maior parte do produto.
A Cloudflare escolheu correção ao custo de capacidade. Para um recurso voltado ao jogador, isso quase sempre é o certo. A alternativa — uma resposta rápida, barata e plausível contendo os dados de outro jogador — não é um trade-off que você consegue defender em um postmortem.
Onde o Peso do Isolamento Realmente Está
Todo sistema multi-tenant toma essa decisão: quais recursos compartilhados você coloca em pool e reutiliza, e quem é dono da invalidação. A resposta para páginas KV de GPU é "o engine de inferência, mal, até o v0.5.22".
Para o estado do jogador no seu jogo, a resposta costuma ser "seu banco de dados, e a responsabilidade é sua". Se você prefere não ter que lidar com a semântica do pool, serviços de estado vinculados à conta mudam a superfície: a horizOn oferece Cloud Save vinculado à conta com leituras e escritas cientes de revisão, onde o cliente resolve conflitos em vez de seus servidores adivinharem qual slot está obsoleto.
Isso não elimina o trabalho de isolamento — nada elimina. Isso o restringe às fronteiras que você realmente controla.
O Que Fazer Esta Semana
Audite todo pool compartilhado que você possui e faça três perguntas. O tamanho do pool cobre de forma comprovável a maior solicitação que o sistema aceita? Uma solicitação rejeitada termina antes de alocar qualquer coisa? O lado receptor verifica se o que chegou corresponde ao que foi reservado?
Se qualquer resposta for "a gente assume que sim", você tem o mesmo bug esperando por uma solicitação longa o suficiente. Comece pelo pool cujo conteúdo seria mais danoso na resposta de outro jogador.
Se você está pronto para parar de manter esse pool para o estado do jogador, experimente a horizOn grátis — autenticação, Cloud Save vinculado à conta, leaderboards, remote config e relatórios de crash, sem precisar montar um banco de dados e um modelo de sessão do zero. A documentação da API mostra a superfície de integração em cerca de dez minutos.
Fonte: Exposição de cache KV entre solicitações no modelo GLM-5.3 Workers AI e nossa remediação com SGLang