블로그로 돌아가기

Cloudflare Workers AI 보안 포스트모템: 크로스 테넌트 KV 캐시 누출 대응 런북

게시일 2026년 10월 27일
Cloudflare Workers AI 보안 포스트모템: 크로스 테넌트 KV 캐시 누출 대응 런북 AI의 도움으로 생성됨

핵심 요약

크로스 테넌트 KV 캐시 누출 사고를 분석하고, 실행 중인 엔진에서 풀 크기를 검증하며 카나리 마커와 지연 시간 신호로 탐지하고 재발을 막는 실전 런북을 게임 개발자에게 제시합니다. Cloudflare GLM-5.3 사고 사례와 C# 수정 패턴을 포함합니다.

플레이어 대상 AI 기능이 다른 스튜디오의 프롬프트 텍스트를 포함한 응답을 반환했습니다. 크래시도 아니고, 500 에러도 아니었습니다. HTTP 200과 호출자 자신의 요청과 일치하는 토큰 수로 전달된 논리적이고 주제에 맞는 답변이었습니다.

이것이 멀티테넌트 버그가 가질 수 있는 최악의 형태입니다. 출력이 그럴듯하기 때문에 아무도 티켓을 제기하지 않습니다. Cloudflare는 2026년 8월 27일 정확히 이런 상황을 겪었습니다. GLM-5.3 Workers AI 모델의 155개 응답에 다른 고객 세션의 데이터가 포함된 것입니다. 영향을 받은 모든 요청은 681,280토큰보다 길었습니다. 수정에는 두 가지 변경(구성 하나, 엔진 패치 하나)이 필요했으며, 탐지 스토리가 수정보다 더 유익합니다.

다음은 런북입니다: 무엇이 고장 났는지, 스택에서 동일한 실패 클래스를 탐지하는 방법, 해결 방법, 그리고 재발을 막는 방법입니다.

실제로 무엇이 고장 났나

두 개의 독립적인 문제가 겹쳤습니다. 둘 중 하나만 있었다면 견딜 수 있었을 것입니다.

문제 1: 아무도 검증하지 않은 용량 불일치

Cloudflare는 1,048,576토큰 컨텍스트 윈도우로 GLM-5.3을 배포했습니다. 프리필 GPU의 KV 캐시 풀은 681,280토큰을 보유했습니다.

이 격차가 중요한 이유는 풀 크기가 구성 값이 아니기 때문입니다. SGLang은 모델 가중치를 로드한 후 남은 GPU 메모리에서 시작 시 풀 크기를 결정합니다. 광고된 컨텍스트 길이는 별도의 구성 값입니다. 시스템에는 이 둘을 일치시키는 강제 장치가 없으며, 둘이 어긋날 때 경고하는 장치도 없습니다.

출시 전 테스트는 통과했습니다. HTTP 상태 코드는 200이었고, 스트리밍 본문에 오류 프레임이 없었으며, 처리량도 정상으로 보였습니다. 테스트는 콘텐츠가 아닌 전송만 검증했습니다.

문제 2: 거부되었지만 여전히 살아 있다고 보고하는 요청

프롬프트가 풀을 초과하면 SGLang의 스케줄러가 이를 거부했습니다. 거부 경로는 클라이언트를 위한 오류를 기록하고 프롬프트를 원-토큰 플레이스홀더로 교체하여 메모리를 할당하지 않고도 요청이 배치 메커니즘을 통해 빠져나갈 수 있게 했습니다. 단일 노드에서는 문제없이 작동했습니다. 플레이스홀더가 실행되고, 배치가 완료되고, 클라이언트는 오류를 받았습니다.

분리형 프리필/디코드 모드(프리필 GPU가 KV 캐시를 구축하고 RDMA를 통해 토큰을 생성하는 디코드 GPU로 전송하는 모드)에서는 다음과 같은 순서로 진행되었습니다:

  1. 부트스트랩. 라우터는 디코드에 프롬프트가 8페이지 길이라고 알렸습니다. 디코드는 8개의 KV 페이지를 예약했습니다.
  2. 프리필 승인. 큐는 req.finished()가 False를 반환하는 모든 요청을 승인했습니다. 거부된 요청은 finished_reason이 아닌 to_finish에 거부를 저장했으므로 finished()는 여전히 False를 반환했습니다. 거부된 요청이 승인되었습니다.
  3. 프리필 포워드 패스. 프롬프트는 이미 원-토큰 플레이스홀더로 교체되었습니다. 프리필은 해당 단일 토큰에 대해 단순한 포워드 패스를 실행했습니다.
  4. KV 전송. 프리필은 1페이지의 KV를 디코드로 보내고 '전송 완료'를 알렸습니다.
  5. 디코드 승인. 디코드의 큐는 동일한 finished() 검사를 사용하여 핸드오프를 수락했습니다.
  6. 디코드 어텐션. 디코드는 예약된 8개 페이지 전체에 어텐션을 적용하면서 토큰을 생성했습니다. 이 요청이 쓴 것은 페이지 0뿐이었습니다. 페이지 1~7에는 여전히 다른 테넌트의 프롬프트와 부분 생성 결과가 남아 있었습니다.
  7. 응답. 클라이언트는 HTTP 200, 일치하는 prompt_tokens 수, 그리고 오래된 캐시 페이지에서 조합된 논리적인 답변을 받았습니다.

681,280토큰 임계값을 초과하는 모든 요청이 이런 식으로 동작했습니다. 이 버그는 2026년 7월 6일 업스트림에 이슈 #30233으로 등록되었으며, 영향을 받은 배포는 아직 수정 사항을 적용하지 않은 상태였습니다.

플레이어보다 먼저 탐지하세요

상태 코드만으로 격리를 테스트할 수는 없습니다. 세 가지 신호가 필요합니다.

신호 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"))

로드 밸런서가 트래픽을 받기 전에 모든 프리필 노드에 대해 실행하세요. 0이 아닌 종료 코드는 배포를 차단합니다.

신호 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: 너무 좋은 지연 시간

전체 길이 프리필은 측정 가능한 시간이 걸립니다. 8페이지 프롬프트가 1페이지 프롬프트가 걸릴 시간에 반환된다면 다른 사람의 캐시를 읽고 있는 것입니다. 프롬프트 길이별로 프리필 지속 시간을 로깅하고 롱컨텍스트 버킷의 p5에서 알림을 발생시키세요. 비정상적으로 빠른 긴 프롬프트는 승리가 아니라 누출 신호입니다.

수정 방법, 순서대로

1. 풀이 전체 컨텍스트를 보유하도록 만들기

Cloudflare는 GLM-5.2와 일치하도록 GLM-5.3 구성을 변경하여 프리필 풀이 약 204만 토큰(1,048,576토큰 컨텍스트의 약 2배)을 보유할 수 있을 만큼 GPU 메모리를 확보했습니다. 이제 컨텍스트 윈도우에 맞는 프롬프트는 항상 풀에도 맞으므로 유효한 요청에 대해 거부 경로에 도달하지 않습니다.

2. 그래도 거부 경로를 안전하게 만들기

풀 크기 조정만으로는 취약합니다. 양자화, 텐서 병렬 처리, CUDA 그래프 크기, 모델 버전의 변경은 놓치기 쉬운 방식으로 풀을 이동시킵니다.

엔진 패치는 부트스트랩, 할당, KV 전송이 발생하기 전에 보류 중인 거부를 가진 요청을 원래 오류와 함께 분리형 승인 큐에서 폐기합니다. 디코드 노드는 또한 전송된 토큰 수가 예약한 수와 일치하는지 확인합니다. 이는 PR #38704로 업스트림에 반영되었고 #38935에서 후속 작업이 이루어졌으며 SGLang v0.5.20~v0.5.22에 포함되었습니다.

SGLang을 어디서든 실행한다면 v0.5.22 이상으로 고정하세요. 이것은 '다음 스프린트에 예약'할 업그레이드가 아닙니다.

3. 감이 아닌 재현으로 검증하기

Cloudflare는 모델을 복원하기 전에 연구자의 재현 스크립트와 자체 카나리 마커 테스트로 두 변경 사항을 모두 검증했습니다. 두 개의 독립적인 테스트, 하나는 적대적이고 하나는 내부 테스트입니다. 모델이나 플릿을 다시 활성화하기 전에 통과해야 하는 기준입니다.

"200을 반환했다"는 테스트가 아닌 이유

근본적인 테스트 실패는 좁고 완전히 평범했습니다. 팀은 모델 출력과 일관성을 검증하지 않고 긴 컨텍스트 길이에서 HTTP 응답 코드와 엔진 성능만 검증했습니다. 벤치마킹이 통과한 이유는 벤치마크가 '응답했는가?'를 묻지 '올바르게 응답했는가?'를 묻지 않기 때문입니다.

호스팅 AI 기능의 경우 정확성 검사는 저렴합니다. 자체 게임 백엔드의 경우 동등한 격차는 대개 더 넓습니다. 전이 가능한 규칙은 다음과 같습니다: 공유 리소스 풀에는 풀 크기를 시스템이 수용할 가장 큰 요청의 크기와 연결하는 검증이 필요하며, 이 검증은 구성 파일이 아닌 라이브 프로세스에 대해 실행되어야 합니다.

동일한 버그 클래스는 게임 백엔드에도 존재합니다

'KV 캐시 페이지'를 '세션 슬롯'으로 바꾸면 이 사고는 거의 줄 단위로 재현됩니다.

매치 슬롯, 플레이어 상태 슬롯, 액터 버퍼의 고정 풀을 할당합니다. 요청이 8개 슬롯을 예약합니다. 승인 검사가 state == Valid 대신 state != Finished를 테스트하므로 거부된 예약이 어쨌든 승인됩니다. 슬롯 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. 시작 시 실행 중인 프로세스에서 풀 크기를 컨텍스트 길이와 대조해 검증하세요. 구성 파일이 아닙니다. 0이 아닌 종료 코드는 배포를 차단합니다.
  2. '거부됨'을 '완료됨'과 구분되는 종료 상태로 취급하세요. 할당이나 핸드오프 전에 승인 단계에서 중단하고 원래 오류를 반환하세요.
  3. 양쪽에서 전송 수를 검증하세요. 디코드가 8페이지를 예약했다면 8페이지가 도착했는지 확인해야 합니다. 1페이지 전송이 '완료'를 보고하는 것이 바로 그 버그입니다.
  4. 콘텐츠 검증으로 실패 경로를 테스트하세요. 카나리 마커, 센티널 문자열, 경계값+1 프롬프트. 상태 코드가 아닌 응답 본문을 확인하세요.
  5. 모든 용량 변경 시 게이트를 다시 실행하세요. 양자화, 텐서 병렬 처리, 배치 크기, 모델 버전 모두 풀을 이동시킵니다. 각각을 격리 관련 변경으로 취급하세요.

항목 5는 팀이 건너뛰는 항목입니다. 모든 용량 변경은 또한 보안 변경입니다. 풀이 선언된 메모리가 아닌 남은 메모리에서 파생되기 때문입니다. 동일한 제로섬 사고방식은 수동으로 크기를 조정하는 모든 리소스 풀에 적용됩니다. 제로 웨이스트 서버 하이버네이션 분석의 트레이드오프 수학도 동일한 산술입니다.

이것의 비용

격리는 공짜가 아니며, 트레이드오프는 정직합니다.

KV 풀을 681,280에서 약 204만 토큰으로 두 배로 늘리면 더 많은 동시 요청을 처리할 수 있는 GPU 메모리를 소비합니다. 동시 요청이 줄어들면 생성 토큰당 비용이 높아집니다. 프리필 플릿에서 그 헤드룸은 판매할 수 없는 용량입니다.

더 저렴한 대안이 있습니다: 작은 풀을 유지하고 스케줄러에 도달하기 전에 엣지에서 명시적 HTTP 400으로 초과 길이 프롬프트를 거부하는 것입니다. 이는 처리량을 보존하지만 합법적인 롱컨텍스트 요청을 실패시킵니다. 에이전틱 코딩 모델의 경우 그것이 제품의 대부분입니다.

Cloudflare는 용량 비용을 감수하고 정확성을 선택했습니다. 플레이어 대상 기능의 경우 그것이 거의 항상 옳습니다. 대안(다른 플레이어의 데이터를 포함한 빠르고 저렴하며 그럴듯한 응답)은 포스트모템에서 변호할 수 없는 트레이드오프입니다.

격리 부담이 실제로 놓이는 곳

모든 멀티테넌트 시스템은 이 결정을 내립니다: 어떤 공유 리소스를 풀링하고 재사용할지, 그리고 무효화를 누가 소유할지. GPU KV 페이지에 대한 답은 'v0.5.22까지는 추론 엔진이, 제대로 하지 못한 채'입니다.

게임의 플레이어 상태에 대한 답은 대개 '당신의 데이터베이스, 그리고 당신의 책임'입니다. 풀 의미론을 전혀 소유하고 싶지 않다면 계정 바인딩 상태 서비스가 표면을 이동시킵니다: horizOn은 리비전 인식 읽기/쓰기를 지원하는 계정 바인딩 Cloud Save를 제공하며, 서버가 어떤 슬롯이 오래된 것인지 추측하는 대신 클라이언트가 충돌을 해결합니다.

그렇다고 격리 작업이 사라지지는 않습니다. 어떤 것도 그렇게 하지 못합니다. 실제로 통제하는 경계로 범위를 좁힐 뿐입니다.

이번 주에 할 일

소유한 모든 공유 풀을 감사하고 세 가지 질문을 하세요. 풀 크기가 시스템이 수용하는 가장 큰 요청을 증명 가능하게 커버하는가? 거부된 요청이 무엇이든 할당하기 전에 종료되는가? 수신 측이 도착한 것이 예약한 것과 일치하는지 검증하는가?

어떤 답이든 '그럴 것이라고 가정합니다'라면, 충분히 긴 요청을 기다리는 동일한 버그가 있는 것입니다. 내용물이 다른 플레이어의 응답에 노출될 때 가장 치명적인 풀부터 시작하세요.

플레이어 상태를 위해 그 풀을 유지 관리하는 것을 중단할 준비가 되었다면 horizOn을 무료로 사용해 보세요. 데이터베이스와 세션 모델을 처음부터 구축하지 않고도 인증, 계정 바인딩 Cloud Save, 리더보드, 원격 구성, 크래시 리포트를 제공합니다. API 문서는 약 10분 안에 통합 표면을 보여줍니다.


출처: GLM-5.3 Workers AI 모델의 교차 요청 KV 캐시 노출 및 SGLang을 통한 해결