Назад к блогу

Постмортем безопасности Cloudflare Workers AI: runbook по межарендаторским утечкам KV-кэша

Опубликовано 27 октября 2026 г.
Постмортем безопасности Cloudflare Workers AI: runbook по межарендаторским утечкам KV-кэша Создано с помощью ИИ

Коротко о главном

Разбираем утечку KV-кэша между арендаторами в Cloudflare Workers AI: как обнаружить, исправить и предотвратить этот класс багов в игровых бэкендах.

ИИ-функция для игроков вернула ответ, содержащий текст промпта другой студии. Не краш. Не 500. Связный ответ по теме, возвращённый с HTTP 200 и числом токенов, совпадающим с запросом отправителя.

Это худшая форма, которую может принять мульти-тенантный баг: вывод правдоподобен, поэтому никто не заводит тикет. Cloudflare столкнулась именно с этим 27 августа 2026 года, когда 155 ответов модели GLM-5.3 Workers AI содержали данные из сессий других клиентов. Каждый затронутый запрос был длиннее 681 280 токенов. Исправление потребовало двух изменений — одно в конфигурации, одно в патче движка — и история обнаружения поучительнее самого исправления.

Вот runbook: что сломалось, как обнаружить тот же класс отказа в вашем стеке, как его устранить и как предотвратить повторение.

Что именно сломалось

Две независимые проблемы наложились друг на друга. По отдельности каждая из них была бы переживаема.

Проблема первая: несоответствие ёмкости, которое никто не проверял

Cloudflare развернула GLM-5.3 с контекстным окном на 1 048 576 токенов. Пул KV-кэша на prefill-GPU вмещал 681 280 токенов.

Этот разрыв важен, потому что размер пула — не значение конфигурации. SGLang вычисляет его при старте из памяти GPU, оставшейся после загрузки весов модели. Заявленная длина контекста — отдельное значение конфигурации. В системе ничто не заставляет их совпадать, и ничто не предупреждает о расхождении.

Предрелизное тестирование прошло. HTTP-статусы были 200, тела стриминговых ответов не содержали фреймов ошибок, пропускная способность выглядела нормальной. Тесты проверяли транспорт, а не содержимое.

Проблема вторая: отклонённый запрос, который продолжал считать себя живым

Когда промпт превышал пул, планировщик SGLang отклонял его. Путь отклонения записывал ошибку для клиента и подменял промпт плейсхолдером на один токен, чтобы запрос мог пройти через батч-механизм без выделения памяти. На одном узле это работало нормально: плейсхолдер выполнялся, батч завершался, клиент получал ошибку.

В раздельном режиме prefill/decode — когда prefill-GPU строят KV-кэш и передают его по RDMA на decode-GPU, генерирующие токены, — последовательность была такой:

  1. Bootstrap. Роутер сообщил decode, что промпт занимает 8 страниц. Decode зарезервировал 8 KV-страниц.
  2. Приём в prefill. Очередь принимала любой запрос, где req.finished() возвращал False. Отклонённый запрос хранил своё отклонение в to_finish, а не в finished_reason, поэтому finished() по-прежнему возвращал False. Отклонённый запрос был принят.
  3. Forward-pass в prefill. Промпт уже был заменён плейсхолдером на один токен. Prefill выполнил тривиальный forward-pass на этом единственном токене.
  4. Передача KV. Prefill отправил одну страницу KV на decode и подал сигнал «передача завершена».
  5. Приём в decode. Очередь decode использовала ту же проверку finished() и приняла передачу.
  6. Decode attention. Decode генерировал токены, выполняя attention по всем 8 зарезервированным страницам. Только страница 0 была записана этим запросом. На страницах с 1 по 7 всё ещё содержались промпты и частичные генерации других арендаторов.
  7. Ответ. Клиент получил HTTP 200, совпадающее число prompt_tokens и связный ответ, собранный из устаревших страниц кэша.

Каждый запрос сверх порога в 681 280 токенов вёл себя именно так. Баг был зарегистрирован в апстриме как issue #30233 6 июля 2026 года, и затронутый деплоймент ещё не подтянул исправление.

Обнаружьте это раньше, чем ваши игроки

Статус-кодами изоляцию не протестировать. Нужны три сигнала.

Сигнал 1: размер пула против длины контекста, считанный с работающего движка

Проверяйте это при старте и в CI, а не из конфиг-файла. Пул вычисляется из остаточной памяти, поэтому изменение квантизации, тензорного параллелизма или размера CUDA-графа незаметно сдвигает его.

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

Запускайте его на каждом prefill-узле до того, как балансировщик начнёт принимать трафик. Ненулевой код возврата блокирует деплой.

Сигнал 2: канареечные маркеры в KV-кэше

Встройте уникальную сигнатурную строку в длинный промпт, дайте запросу завершиться, затем отправьте второй длинный промпт, который ни при каких условиях не должен её содержать. Проверяйте текст ответа, а не статус-код.

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"

Прогоняйте это на самом длинном промпте, который вы собираетесь принимать, ровно на границе и на граница + 1 токен. Именно на границе срабатывает путь отклонения.

Сигнал 3: слишком хорошая задержка

Полноразмерный prefill занимает измеримое время. Если промпт на 8 страниц возвращается за время, которое должно уходить на промпт с одной страницей, вы читаете чужой кэш. Логируйте длительность prefill с привязкой к длине промпта и алертите по p5 бакета длинного контекста. Аномально быстрый длинный промпт — это сигнатура утечки, а не победа.

Исправление по порядку

1. Сделайте так, чтобы пул вмещал полный контекст

Cloudflare изменила конфигурацию GLM-5.3 до уровня GLM-5.2, освободив достаточно памяти GPU, чтобы пул prefill теперь вмещал примерно 2,04 миллиона токенов — около двух контекстов по 1 048 576 токенов. Промпт, помещающийся в контекстное окно, теперь всегда помещается и в пул, поэтому для валидного запроса путь отклонения больше не достигается.

2. В любом случае сделайте путь отклонения безопасным

Один лишь расчёт размера пула ненадёжен. Любое изменение квантизации, тензорного параллелизма, размеров CUDA-графов или версии модели сдвигает пул так, что это легко пропустить.

Патч движка снимает с обработки запрос, несущий ожидающее отклонение, на admission-очереди раздельной архитектуры, вместе с его исходной ошибкой, до любого bootstrap, выделения памяти или передачи KV. Узел decode также проверяет, что число переданных токенов совпадает с числом зарезервированных. Это попало в апстрим как PR #38704, с продолжениями в #38935, и вышло в SGLang v0.5.20–v0.5.22.

Если вы где-то запускаете SGLang, зафиксируйте версию v0.5.22 или новее. Это не апдейт из серии «запланируем на следующий спринт».

3. Проверяйте репро, а не ощущения

Cloudflare проверила оба изменения на репро-скрипте исследователя и на собственных канареечных тестах перед восстановлением модели. Два независимых теста: один состязательный, один внутренний. Это правильная планка перед повторным включением модели или флита.

Почему «оно вернуло 200» — это не тест

Корневой сбой тестирования был узким и совершенно обыденным: команда проверяла HTTP-статусы и производительность движка на длинных контекстах, но не проверяла выход модели и связность. Бенчмарки прошли, потому что бенчмарки спрашивают «ответило ли оно?», а не «правильно ли оно ответило?».

Для хостинговой ИИ-функции проверка корректности дёшева. Для вашего собственного игрового бэкенда аналогичный разрыв обычно шире. Вот универсальное правило: для любого общего пула ресурсов нужна проверка, связывающая размер пула с размером самого большого запроса, который система принимает, и эта проверка должна выполняться на живом процессе, а не на конфиг-файле.

Тот же класс багов живёт в игровых бэкендах

Замените «страницы KV-кэша» на «слоты сессий» — и инцидент воспроизведётся почти строчка в строчку.

Вы выделяете фиксированный пул слотов матчей, слотов состояния игроков или буферов акторов. Запрос резервирует 8 слотов. Admission-проверка тестирует state != Finished вместо state == Valid, поэтому отклонённая резервация всё равно принимается. Слот передаётся следующему лобби, в котором инициализирован только слот 0. В слотах с 1 по 7 всё ещё хранятся ID игроков, инвентарь и данные позиций прошлого матча.

Это тот же режим отказа, который мы рассматривали в статье «Проектирование игровых бэкендов, устойчивых к компрометации»: утечке не нужна сложная атака, ей нужна лишь модель владения, которую код на самом деле не обеспечивает.

Вот ошибочный паттерн и исправление на 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;
    }
}

Счётчик поколений — это дешёвая часть. Он превращает «надеемся, слот был перезаписан» в жёсткое, тестируемое отклонение. Любое чтение по устаревшему хендлу падает, вместо того чтобы молча вернуть данные другого игрока.

Чек-лист изоляции

  1. Проверяйте размер пула против длины контекста при старте, из работающего процесса. Не из конфига. Ненулевой код возврата блокирует деплой.
  2. Считайте «rejected» терминальным состоянием, отличным от «finished». Прерывайте на admission, до любого выделения памяти или передачи, и возвращайте исходную ошибку.
  3. Проверяйте объёмы передачи на обеих сторонах. Если decode зарезервировал 8 страниц, он должен подтвердить, что прибыло 8. Передача одной страницы с отчётом «завершено» — это и есть тот самый баг.
  4. Тестируйте путь отказа проверками содержимого. Канареечные маркеры, сигнатурные строки, промпты на границе плюс один токен. Проверяйте тело ответа, никогда — статус-код.
  5. Перезапускайте гейт при каждом изменении ёмкости. Квантизация, тензорный параллелизм, размер батча и версия модели — всё это сдвигает пул. Считайте каждое из них изменением, релевантным изоляции.

Пункт 5 — тот, который команды пропускают. Каждое изменение ёмкости — это также изменение безопасности, потому что пул вычисляется из остаточной памяти, а не объявляется. Та же логика нулевой суммы применима к любому пулу ресурсов, размер которого вы задаёте вручную, — математика компромиссов в анализе безотходной серверной гибернации — это та же арифметика.

Сколько это стоит

Изоляция не бесплатна, и компромисс здесь честный.

Удвоение KV-пула с 681 280 до примерно 2,04 миллиона токенов съедает память GPU, которая могла бы держать больше параллельных запросов. Меньше параллельных запросов — выше стоимость за сгенерированный токен. На prefill-флоте этот запас — ёмкость, которую вы не можете продать.

Есть более дешёвая альтернатива: оставить маленький пул и отклонять сверхдлинные промпты явным HTTP 400 на границе, до попадания в планировщик. Это сохраняет пропускную способность, но валит легитимные длинноконтекстные запросы — а для агентной модели, генерирующей код, это большая часть продукта.

Cloudflare выбрала корректность ценой ёмкости. Для функции, обращённой к игрокам, это почти всегда правильно. Альтернатива — быстрый, дешёвый, правдоподобный ответ с данными другого игрока — это не тот компромисс, который можно отстоять в постмортеме.

Где на самом деле лежит бремя изоляции

Каждая мульти-тенантная система принимает это решение: какие общие ресурсы вы объединяете в пул и переиспользуете, и кто владеет инвалидацией. Ответ для GPU KV-страниц — «движок вывода, и плохо, до v0.5.22».

Для состояния игроков вашей игры ответ обычно — «ваша база данных, и это на вас». Если вы вообще не хотите владеть семантикой пула, сервисы состояния, привязанного к аккаунту, сдвигают поверхность атаки: horizOn предоставляет привязанный к аккаунту Cloud Save с чтением и записью с учётом ревизий, где конфликты разрешает клиент, а не ваши серверы гадают, какой слот устарел.

Это не устраняет работу по изоляции — её не устраняет ничто. Это сужает её до границ, которые вы реально контролируете.

Что сделать на этой неделе

Проведите аудит каждого общего пула, которым владеете, и задайте три вопроса. Доказуемо ли размер пула покрывает самый большой запрос, который принимает система? Завершается ли отклонённый запрос до того, как выделит что-либо? Проверяет ли принимающая сторона, что прибывшее соответствует зарезервированному?

Если на любой вопрос ответ «мы так предполагаем», у вас тот же баг, ждущий достаточно длинного запроса. Начните с пула, содержимое которого было бы самым разрушительным в ответе другому игроку.

Если вы готовы перестать поддерживать этот пул для состояния игроков, попробуйте horizOn бесплатно — аутентификация, привязанный к аккаунту Cloud Save, лидерборды, удалённая конфигурация и краш-репорты, без разворачивания базы данных и модели сессий с нуля. Документация API покажет поверхность интеграции примерно за десять минут.


Источник: Межзапросное раскрытие KV-кэша в модели GLM-5.3 Workers AI и наше исправление с SGLang