Постмортем безопасности 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, генерирующие токены, — последовательность была такой:
- Bootstrap. Роутер сообщил decode, что промпт занимает 8 страниц. Decode зарезервировал 8 KV-страниц.
- Приём в prefill. Очередь принимала любой запрос, где
req.finished()возвращал False. Отклонённый запрос хранил своё отклонение вto_finish, а не вfinished_reason, поэтомуfinished()по-прежнему возвращал False. Отклонённый запрос был принят. - Forward-pass в prefill. Промпт уже был заменён плейсхолдером на один токен. Prefill выполнил тривиальный forward-pass на этом единственном токене.
- Передача KV. Prefill отправил одну страницу KV на decode и подал сигнал «передача завершена».
- Приём в decode. Очередь decode использовала ту же проверку
finished()и приняла передачу. - Decode attention. Decode генерировал токены, выполняя attention по всем 8 зарезервированным страницам. Только страница 0 была записана этим запросом. На страницах с 1 по 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;
}
}
Счётчик поколений — это дешёвая часть. Он превращает «надеемся, слот был перезаписан» в жёсткое, тестируемое отклонение. Любое чтение по устаревшему хендлу падает, вместо того чтобы молча вернуть данные другого игрока.
Чек-лист изоляции
- Проверяйте размер пула против длины контекста при старте, из работающего процесса. Не из конфига. Ненулевой код возврата блокирует деплой.
- Считайте «rejected» терминальным состоянием, отличным от «finished». Прерывайте на admission, до любого выделения памяти или передачи, и возвращайте исходную ошибку.
- Проверяйте объёмы передачи на обеих сторонах. Если decode зарезервировал 8 страниц, он должен подтвердить, что прибыло 8. Передача одной страницы с отчётом «завершено» — это и есть тот самый баг.
- Тестируйте путь отказа проверками содержимого. Канареечные маркеры, сигнатурные строки, промпты на границе плюс один токен. Проверяйте тело ответа, никогда — статус-код.
- Перезапускайте гейт при каждом изменении ёмкости. Квантизация, тензорный параллелизм, размер батча и версия модели — всё это сдвигает пул. Считайте каждое из них изменением, релевантным изоляции.
Пункт 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