프롬프트가 KV 풀보다 길면 서버는 400을 돌려줘야 합니다. Cloudflare의 GLM-5.3은 200을 돌려줬고, 그 응답은 다른 사용자의 데이터가 남은 캐시 페이지를 읽고 만들어졌습니다.
Cloudflare는 10월 26일 사고 보고서를 공개했습니다. 노출 기간은 8월 27일부터 9월 9일까지이고, 영향받은 응답은 155개입니다. 모두 681,280토큰보다 긴 프롬프트에 대한 응답이었습니다. 이 글은 사고 요약을 반복하기보다, 같은 구조를 직접 운영한다면 무엇을 확인해야 하는지에 초점을 둡니다.
숫자 두 개가 어긋나 있었다
원인은 두 가지가 겹친 것입니다.
| 항목 | 값 | 출처 |
|---|---|---|
| 배포된 컨텍스트 길이 | 1,048,576 토큰 | Cloudflare 보고서 |
| prefill KV 풀 | 681,280 토큰 | Cloudflare 보고서 |
| 수정 후 prefill KV 풀 | 약 204만 토큰 | Cloudflare 보고서 |
첫 번째는 설정 불일치입니다. 컨텍스트 창은 약 104만 토큰으로 광고하는데 KV 풀은 그보다 작았습니다. Cloudflare는 출시 전에 이 차이를 알고 있었다고 밝혔습니다. 테스트에서 오류 프레임 없이 HTTP 성공 코드가 돌아오자, SGLang이 차이를 알아서 처리한다고 가정했습니다. "성공 코드가 돌아왔다"를 "안전하게 거절했다"로 읽은 셈입니다.
두 번째는 SGLang의 거절 처리입니다. 보고서에 따르면 풀보다 큰 프롬프트가 들어오면 Req.set_finish_with_abort가 오류를 기록하고 프롬프트를 토큰 1개짜리 자리표시자로 바꿉니다. 그런데 분리형(disaggregated) 구성의 입장 큐는 거절 여부가 아니라 finished()를 확인했습니다. 거절된 요청이 살아 있는 요청처럼 보였고, prefill이 이를 받아 KV를 decode로 넘겼습니다. decode는 전체 프롬프트 길이만큼 페이지를 확보했지만 현재 요청이 쓴 것은 첫 페이지뿐이었습니다. 나머지 페이지에는 이전 사용자의 데이터가 남아 있었고, decode는 그것을 참조해 응답했습니다. 같은 버그를 설명한 이슈(#30233)는 7월 6일에 다른 사용자가 이미 올려 두었다고 합니다.
패턴만 떼어 재현해 보기
아래는 SGLang 코드가 아니라 이 결함 패턴만 단순화한 장난감 모델입니다. 직접 실행했고, 실제 엔진의 동작을 재현했다는 뜻은 아닙니다. 핵심은 "거절된 요청도 페이지를 할당받고, 일부만 쓴 채 읽는다"는 구조입니다.
PAGE = 4 # 페이지당 토큰 수 (장난감 값)
class Pool:
def __init__(self, pages):
self.free = list(range(pages))
self.mem = {p: None for p in self.free} # 페이지 -> 마지막으로 쓴 사용자
def alloc(self, n):
got, self.free = self.free[:n], self.free[n:]
return got
def release(self, pages):
self.free += pages
def serve(pool, user, prompt_tokens, pool_tokens, check_rejected_at_admission):
need = -(-prompt_tokens // PAGE)
rejected = prompt_tokens > pool_tokens
if rejected and check_rejected_at_admission:
return "HTTP 400", []
pages = pool.alloc(need)
written = pages[:1] if rejected else pages # 거절 요청은 첫 페이지만 기록
for p in written:
pool.mem[p] = user
seen = {pool.mem[p] for p in pages}
pool.release(pages)
return "HTTP 200", sorted(s for s in seen if s and s != user)
앨리스가 24토큰 요청을 처리한 뒤, 풀 한도(32토큰)를 넘는 40토큰 요청을 보내는 공격자가 같은 풀을 쓰는 상황을 돌렸습니다.
reject-at-admission=False: HTTP 200, other users' data visible to mallory: ['alice']
reject-at-admission=True: HTTP 400, other users' data visible to mallory: []
입장 단계에서 거절하면 페이지를 건드리기 전에 끝납니다. Cloudflare가 적용한 패치도 같은 방향입니다. 거절된 요청이 부트스트랩, 할당, KV 전송 전에 원래 오류를 돌려주게 했습니다. 이 모델이 말해 주지 못하는 것도 분명합니다. 실제 사고에서 누출된 내용의 구성, Mooncake 전송 경로, 페이지 해제 시점의 경쟁 상태는 여기에 없습니다.
운영자가 확인할 항목
Cloudflare가 SGLang 운영자에게 권고한 사항을 바탕으로, 점검 순서로 다시 정리했습니다.
- KV 풀 크기를 로그에서 읽어 컨텍스트 길이와 비교하세요. prefill·decode 노드마다 시작 로그의 풀 크기를 확인합니다. 가중치, 양자화, 병렬화, 메모리 설정을 바꿀 때마다 다시 비교해야 합니다. 앞 장난감 코드의 사전 점검은 한 줄입니다. 이번 사고 값으로는
FAIL pool 681280 < context 1048576이 나옵니다. - v0.5.22 이상으로 올리세요. 보고서 표에 따르면 v0.5.21의 #38935가 길이 초과 요청을 입장 단계에서 거절하고, v0.5.20의 #36988이 중단된 prefill 요청을 정리합니다. Cloudflare는 v0.5.22 이상을 권합니다. 후속 #39729는 병합됐지만 아직 릴리스되지 않았다고 했습니다.
- 지연 KV 해제를 두 역할 모두에 적용하고 Mooncake 전송 배치를 제한하세요.
- 손상 감지를 켜고 실제로 동작하는지 로그로 확인하세요.
--disaggregation-enable-kv-checksum,--kv-canary가 대상이며, 모델 아키텍처가 지원하는 경우에 한합니다. - 로드 밸런서가 첫 토큰 전에 불필요하게 요청을 끊지 않게 하세요. 전송 도중 중단이 위험한 경로를 만들 수 있기 때문입니다.
Cloudflare는 풀 크기를 컨텍스트의 약 두 배로 키웠고, 배포 전 점검에 풀 크기 대 컨텍스트 길이, 지연 해제, 전송 한도, 필수 패치 확인을 추가했다고 밝혔습니다.
이 사고에서 읽을 수 있는 것, 읽을 수 없는 것
공식 보고서가 직접 밝힌 사실과, 독자가 추가로 조심해야 할 점을 나눠 봅니다.
- 밝혀진 것: 영향받은 모델은 GLM-5.3뿐이고, Cloudflare가 자체 테스트와 연구자 테스트를 제외하고 본 요청은 평소 장문 사용과 시점·규모가 일치했습니다. 의도적 악용의 증거는 없다고 합니다. 모델은 9월 9일 14:00 UTC에 비공개로 전환됐다가 19:40 UTC에 복구됐습니다.
- 조심할 것: 155개는 Cloudflare의 조사 결과입니다. 어떤 내용이 담겼는지, 누구의 데이터인지는 보고서에 자세히 나오지 않습니다. 이 숫자를 독립 기관이 검증했다는 근거는 이번에 확인하지 못했습니다.
- 교훈에 가까운 것: 외부에서 보면 성공 응답입니다. HTTP 200은 정상이라는 뜻이 아닙니다. 경계 조건(컨텍스트 한도 직전, 한도 초과)을 일부러 시험하는 테스트가 없으면 이런 결함은 오류 로그에 남지 않습니다.
직접 운영하지 않는 사용자에게는 행동할 항목이 많지 않습니다. 다만 관리형 추론 서비스에 민감한 문서를 아주 긴 컨텍스트로 보내고 있다면, 공급자에게 요청 간 캐시 격리와 경계 조건 테스트를 어떻게 하는지 물어볼 만한 사례입니다.
정리
이 사고는 모델이나 프롬프트의 문제가 아니라 서빙 설정과 큐 처리의 문제였습니다. 안전장치는 "거절"이 실제로 거절 경로를 타는지 확인하는 데 있었습니다. 풀 크기와 컨텍스트 길이의 비교는 배포 파이프라인에 넣으면 한 줄짜리 검사이고, 입장 단계의 거절은 엔진 업그레이드로 해결됩니다.
함께 읽으면 좋은 글
- DeepInfer 공개: DeepSeek 추론 엔진에서 실제로 열린 것과 빠진 것
- AI 에이전트 보안 입문: computer-use 시대에 권한 없이 실행하면 안 되는 이유
- 에이전트가 DNS로 외부와 통신한 2시간 44분: OpenAI 격리 강화에서 배울 것