Retour au Blog

Postmortem de sécurité Cloudflare Workers AI : un runbook pour les fuites de cache KV inter-locataires

Publié le 27 octobre 2026
Postmortem de sécurité Cloudflare Workers AI : un runbook pour les fuites de cache KV inter-locataires Généré avec l'aide de l'IA

En bref

Découvrez comment détecter et corriger les fuites de cache KV inter-locataires dans les backends de jeux avec le runbook de l'incident Cloudflare.

Une fonctionnalité IA destinée aux joueurs a renvoyé une réponse contenant le texte d'un prompt d'un autre studio. Pas un crash. Pas une 500. Une réponse cohérente, pertinente, livrée avec un HTTP 200 et un compte de tokens correspondant à la requête de l'appelant.

C'est la pire forme que peut prendre un bug multi-locataire : la sortie est plausible, donc personne ne crée de ticket. Cloudflare a connu exactement cela le 27 août 2026, lorsque 155 réponses du modèle GLM-5.3 Workers AI ont contenu des données provenant des sessions d'autres clients. Chaque requête affectée dépassait 681 280 tokens. Le correctif a nécessité deux changements — une configuration, un patch moteur — et l'histoire de la détection est plus instructive que le correctif.

Voici le runbook : ce qui a cassé, comment détecter la même classe de défaillance dans votre stack, comment y remédier et comment empêcher que cela ne se reproduise.

Ce qui a réellement cassé

Deux problèmes indépendants se sont superposés. Pris séparément, chacun aurait été survivable.

Problème un : un écart de capacité sur lequel personne n'a émis d'assertion

Cloudflare a déployé GLM-5.3 avec une fenêtre de contexte de 1 048 576 tokens. Le pool de cache KV sur les GPU de prefill contenait 681 280 tokens.

Cet écart compte car la taille du pool n'est pas une valeur de configuration. SGLang la dimensionne au démarrage à partir de la mémoire GPU restante après le chargement des poids du modèle. La longueur de contexte annoncée est une valeur de configuration distincte. Rien dans le système ne force les deux à correspondre, et rien ne vous avertit lorsqu'elles divergent.

Les tests de pré-lancement sont passés. Les codes de statut HTTP étaient 200, les corps de streaming ne contenaient aucune trame d'erreur, le débit semblait normal. Les tests validaient le transport, pas le contenu.

Problème deux : une requête rejetée qui se déclarait toujours vivante

Lorsqu'un prompt dépassait le pool, le planificateur de SGLang le rejetait. Le chemin de rejet enregistrait l'erreur pour le client et remplaçait le prompt par un espace réservé d'un token afin que la requête puisse s'écouler dans la mécanique de batch sans allouer de mémoire. Sur un nœud unique, cela fonctionnait : l'espace réservé s'exécutait, le batch se terminait, le client recevait une erreur.

En mode prefill/decode désagrégé — où les GPU de prefill construisent le cache KV et l'envoient par RDMA aux GPU de decode qui génèrent les tokens — la séquence était la suivante :

  1. Bootstrap. Le routeur a indiqué au decode que le prompt faisait 8 pages. Le decode a réservé 8 pages KV.
  2. Admission en prefill. La file acceptait toute requête où req.finished() renvoyait False. Une requête rejetée stockait son rejet dans to_finish, pas dans finished_reason, donc finished() renvoyait toujours False. La requête rejetée était admise.
  3. Forward pass prefill. Le prompt avait déjà été remplacé par un espace réservé d'un token. Le prefill a exécuté un forward pass trivial sur ce token unique.
  4. Transfert KV. Le prefill a envoyé une page de KV au decode et a signalé « transfert terminé ».
  5. Admission en decode. La file du decode utilisait le même contrôle finished() et acceptait le transfert.
  6. Attention decode. Le decode générait des tokens tout en portant son attention sur les 8 pages réservées. Seule la page 0 avait été écrite par cette requête. Les pages 1 à 7 contenaient encore les prompts et générations partielles d'autres locataires.
  7. Réponse. Le client a reçu un HTTP 200, un compte prompt_tokens correspondant, et une réponse cohérente assemblée à partir de pages de cache obsolètes.

Chaque requête au-dessus du seuil de 681 280 tokens se comportait ainsi. Le bug avait été remonté en amont sous le numéro #30233 le 6 juillet 2026, et le déploiement affecté n'avait pas encore intégré le correctif.

Détectez-le avant vos joueurs

Vous ne pouvez pas obtenir l'isolation à coups de tests sur les codes de statut. Il vous faut trois signaux.

Signal 1 : taille du pool contre longueur de contexte, lue depuis le moteur en cours d'exécution

Vérifiez cela au démarrage et dans la CI, pas à partir d'un fichier de config. Le pool est dérivé de la mémoire restante, donc un changement de quantification, de parallélisme de tenseurs ou une modification de la taille des graphes CUDA le déplace silencieusement.

# 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"))

Exécutez-le sur chaque nœud de prefill avant que le load balancer ne reçoive du trafic. Une sortie non nulle bloque le déploiement.

Signal 2 : marqueurs canary dans le cache KV

Placez une chaîne sentinelle unique dans un prompt long, laissez la requête se terminer, puis envoyez un second prompt long qui ne devrait jamais la contenir. Vérifiez le texte renvoyé, pas le code de statut.

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"

Exécutez ceci avec le prompt le plus long que vous comptez accepter, exactement à la limite, et à limite + 1 token. C'est le cas limite qui déclenche le chemin de rejet.

Signal 3 : une latence trop belle pour être vraie

Un prefill complet prend un temps mesurable. Si un prompt de 8 pages revient dans le temps qu'un prompt d'une page devrait prendre, vous lisez le cache de quelqu'un d'autre. Journalisez la durée de prefill indexée par longueur de prompt et déclenchez une alerte sur le p5 du compartiment long-contexte. Un prompt long anormalement rapide est une signature de fuite, pas une victoire.

Le correctif, dans l'ordre

1. Faire en sorte que le pool couvre tout le contexte

Cloudflare a modifié la configuration de GLM-5.3 pour l'aligner sur GLM-5.2, libérant assez de mémoire GPU pour que le pool de prefill contienne désormais environ 2,04 millions de tokens — environ deux fois le contexte de 1 048 576 tokens. Un prompt qui tient dans la fenêtre de contexte tient maintenant toujours dans le pool, donc le chemin de rejet n'est jamais atteint pour une requête valide.

2. Rendre le chemin de rejet sûr quand même

Le dimensionnement du pool seul est fragile. Tout changement de quantification, de parallélisme de tenseurs, de taille des graphes CUDA, ou de version de modèle déplace le pool d'une manière facile à manquer.

Le patch moteur retire une requête portant un rejet en attente dans la file d'admission de la désagrégation, avec son erreur d'origine, avant tout bootstrap, allocation ou transfert KV. Le nœud de decode vérifie également que le nombre de tokens transférés correspond au nombre réservé. Cela a été intégré en amont dans la PR #38704, avec des suivis dans #38935, et livré dans SGLang v0.5.20 à v0.5.22.

Si vous exécutez SGLang quelque part, verrouillez la version v0.5.22 ou ultérieure. Ce n'est pas une mise à niveau à planifier au prochain sprint.

3. Vérifiez avec la reproduction, pas avec des intuitions

Cloudflare a validé les deux changements avec le script de reproduction du chercheur et ses propres tests de marqueurs canary avant de restaurer le modèle. Deux tests indépendants, l'un adversarial et l'autre interne. C'est le niveau d'exigence correct avant de réactiver un modèle ou une flotte.

Pourquoi « Ça a renvoyé 200 » n'est pas un test

L'échec fondamental des tests était étroit et parfaitement ordinaire : l'équipe a validé les codes de réponse HTTP et la performance du moteur sur de longues longueurs de contexte, sans valider la sortie et la cohérence du modèle. Les benchmarks sont passés parce qu'ils demandent « a-t-il répondu ? », pas « a-t-il correctement répondu ? »

Pour une fonctionnalité IA hébergée, la vérification de l'exactitude est peu coûteuse. Pour votre propre backend de jeu, l'écart équivalent est généralement plus large. Voici la règle transférable : tout pool de ressources partagées a besoin d'une assertion qui lie la taille du pool à la taille de la plus grande requête que le système acceptera, et cette assertion doit s'exécuter contre le processus en cours d'exécution, pas un fichier de config.

La même classe de bug existe dans les backends de jeux

Remplacez « pages de cache KV » par « slots de session » et l'incident se reproduit presque ligne pour ligne.

Vous allouez un pool fixe de slots de match, de slots d'état joueur ou de buffers d'acteurs. Une requête réserve 8 slots. Un contrôle d'admission teste state != Finished au lieu de state == Valid, donc une réservation rejetée est admise quand même. Le slot est transmis au lobby suivant avec seulement le slot 0 initialisé. Les slots 1 à 7 contiennent encore les identifiants joueurs, l'inventaire et les données de position du match précédent.

C'est le même mode de défaillance que nous avons couvert dans l'architecture de backends de jeux capables de survivre aux compromissions : la fuite n'a pas besoin d'être une attaque sophistiquée, elle a juste besoin d'un modèle de propriété que le code n'applique pas réellement.

Voici le motif bugué et le correctif, en 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;
    }
}

Le compteur de génération est la partie peu coûteuse. Il transforme « on espère que le slot a été écrasé » en un rejet dur et testable. Toute lecture avec un handle obsolète échoue au lieu de renvoyer silencieusement les données d'un autre joueur.

La check-list d'isolation

  1. Assertez la taille du pool contre la longueur de contexte au démarrage, depuis le processus en cours d'exécution. Pas depuis la config. Une sortie non nulle bloque le déploiement.
  2. Traitez « rejeté » comme un état terminal distinct de « terminé ». Interrompez à l'admission, avant toute allocation ou transfert, et renvoyez l'erreur d'origine.
  3. Vérifiez les compteurs de transfert des deux côtés. Si le decode a réservé 8 pages, il doit confirmer que 8 pages sont arrivées. Un transfert d'une page signalant « terminé » est exactement le bug.
  4. Testez le chemin de défaillance avec des assertions de contenu. Marqueurs canary, chaînes sentinelles, prompts limite-plus-un. Vérifiez le corps de la réponse, jamais le code de statut.
  5. Relancez le gate à chaque changement de capacité. La quantification, le parallélisme de tenseurs, la taille de batch et la version du modèle déplacent tous le pool. Traitez chacun comme un changement pertinent pour l'isolation.

Le point 5 est celui que les équipes sautent. Chaque changement de capacité est aussi un changement de sécurité, car le pool est dérivé de la mémoire restante plutôt que déclaré. Le même raisonnement à somme nulle s'applique à tout pool de ressources dimensionné à la main — les calculs d'arbitrage dans l'analyse de l'hibernation de serveurs zéro-déchet sont la même arithmétique.

Combien ça coûte

L'isolation n'est pas gratuite, et l'arbitrage est honnête.

Doubler le pool KV de 681 280 à environ 2,04 millions de tokens consomme de la mémoire GPU qui accueillerait sinon plus de requêtes concurrentes. Moins de requêtes concurrentes signifie un coût plus élevé par token généré. Sur une flotte de prefill, cette marge est une capacité que vous ne pouvez pas vendre.

Il existe une alternative moins chère : garder le petit pool et rejeter les prompts de longueur excessive avec un HTTP 400 explicite à la périphérie, avant qu'ils n'atteignent le planificateur. Cela préserve le débit mais fait échouer les requêtes long-contexte légitimes — ce qui, pour un modèle de codage agentique, représente l'essentiel du produit.

Cloudflare a choisi l'exactitude au prix de la capacité. Pour une fonctionnalité destinée aux joueurs, c'est presque toujours la bonne décision. L'alternative — une réponse rapide, bon marché et plausible contenant les données d'un autre joueur — n'est pas un arbitrage que vous pouvez défendre dans un postmortem.

Où repose réellement la charge de l'isolation

Chaque système multi-locataire prend cette décision : quelles ressources partagées mutualisez-vous et réutilisez-vous, et qui possède l'invalidation. Pour les pages KV GPU, la réponse est « le moteur d'inférence, mal, jusqu'à la v0.5.22 ».

Pour l'état joueur de votre jeu, la réponse est généralement « votre base de données, et c'est sur vous ». Si vous préférez ne pas posséder du tout la sémantique du pool, les services d'état liés au compte déplacent la surface d'attaque : horizOn fournit une Cloud Save liée au compte avec des lectures et écritures conscientes des révisions, où le client résout les conflits plutôt que vos serveurs devinant quel slot est obsolète.

Cela n'élimine pas le travail d'isolation — rien ne le fait. Cela le réduit aux frontières que vous contrôlez réellement.

Que faire cette semaine

Auditez chaque pool partagé que vous possédez et posez-vous trois questions. La taille du pool couvre-t-elle de manière prouvable la plus grande requête que le système accepte ? Une requête rejetée se termine-t-elle avant d'allouer quoi que ce soit ? Le côté récepteur vérifie-t-il que ce qui est arrivé correspond à ce qu'il a réservé ?

Si une réponse est « on suppose que oui », vous avez le même bug qui n'attend qu'une requête assez longue. Commencez par le pool dont le contenu serait le plus dommageable dans la réponse d'un autre joueur.

Si vous êtes prêt à arrêter de maintenir ce pool pour l'état joueur, essayez horizOn gratuitement — authentification, Cloud Save liée au compte, classements, configuration à distance et rapports de crash, sans monter une base de données et un modèle de session de zéro. La documentation API montre la surface d'intégration en environ dix minutes.


Source : Exposition du cache KV entre requêtes dans le modèle GLM-5.3 Workers AI, et notre remédiation avec SGLang