LLM API가 HTTP 200과 자연스러운 답변을 반환해도, 그 답변이 올바른 사용자의 데이터에서 생성됐는지는 별도 문제입니다. Cloudflare는 Workers AI의 GLM-5.3에서 다른 고객의 요청 데이터가 응답에 섞인 사건을 공개했습니다.[11]
이 글은 LLM 서비스를 붙이는 개발자와 자체 inference 운영자를 위한 해설입니다. 모델의 지능보다 공유 메모리를 누가 읽고, 누가 아직 쓰고 있으며, 언제 재사용해도 되는가를 살펴봅니다.
💡 📅 표시 날짜는 2026년 10월 27일, 주제 선정 기준은 그날 09:00 KST 이전 공개입니다. 실제 자료 확인은 10월 29일 KST에 했습니다. 사건 보고서는 공개 이후 갱신된 현재본을 읽었으며, 당시 본문을 복원한 글은 아닙니다. 기술 문서는 cutoff 이전 commit으로 고정해 대조했습니다.
💡 💡 핵심: 요청 종료는 메모리 사용 종료의 증거가 아닙니다. 비동기 전송이 남아 있다면 취소된 요청도 다른 요청의 메모리를 덮어쓸 수 있습니다.[12] 오류 응답, 전송 완료, 안전한 자원 해제를 각각 검증해야 합니다.
SGLang 문서는 LLM inference를 입력 전체를 처리하는 Prefill과, KV cache를 사용해 토큰을 생성하는 Decode로 나눕니다.[40] 여기서 KV는 Key·Value이며, 이 글에서 다루는 대상은 일반적인 웹 응답 cache나 Cloudflare Workers KV 제품이 아닙니다.
PD Disaggregation은 두 단계를 서로 다른 engine에 배치해 각각 최적화하는 방식입니다.[40] Cloudflare의 해당 배포에서는 Prefill GPU가 만든 cache를 Mooncake를 통한 RDMA로 Decode GPU에 옮겼습니다.[11]
그 cache는 요청마다 새로 만드는 독립 메모리 덩어리가 아니라, 페이지를 할당하고 돌려받는 공유 pool입니다.[11] Cloudflare는 반환된 페이지를 매번 0으로 지우지 않고 다음 요청이 덮어쓰는 구조였다고 설명합니다.[11]
설계 관점의 해석: 중요한 계약은 “페이지를 할당받았다”가 아니라 “이 요청이 읽을 모든 페이지에 올바른 데이터가 채워졌고, 다른 작성자가 더 이상 건드릴 수 없다”입니다.
Cloudflare의 설정상 context window는 1,048,576 tokens였지만 Prefill KV pool은 681,280 tokens만 수용했습니다.[11] 설정상 허용되는 입력이 실제 처리 자원에는 들어가지 않는 구간이 있었습니다.[11]
사건 보고서가 설명하는 흐름은 다음과 같습니다.[11]
to_finish에 남지만 입장 검사는 finished_reason을 보는 finished()를 사용해 요청을 계속 통과시킵니다.[11][39]이는 단순한 답변 hallucination으로 설명할 문제가 아닙니다. 보고서가 지목한 원인은 배포 설정과 inference engine의 요청 격리 실패입니다.[11][12]
별도의 관련 race에서는 요청 A를 취소한 뒤 Decode가 A의 페이지를 요청 B에 재할당합니다.[12] 그런데 이미 시작된 A의 RDMA 쓰기가 나중에 도착하면 B의 cache를 덮어씁니다.[12]
첫 번째는 거부된 요청의 handoff 문제이고, 두 번째는 진행 중인 비동기 쓰기와 페이지 재사용의 수명 문제입니다.[11][12] 결과가 비슷하다고 같은 수정 하나로 묶으면 적용 범위를 놓치게 됩니다.
💡 ⚠️
cancel호출이 끝났거나 전송 함수가 오류를 반환했다는 사실만으로 “이 페이지에 더 이상 쓰기가 오지 않는다”고 판단할 수 없습니다.[12][42]
다음은 공개 자료를 바탕으로 한 점검 설계 제안입니다. 사용자 서버를 조사하거나 패치한 기록은 아닙니다.
Cloudflare는 engine 시작 로그에서 실제 KV pool을 확인하고, weights·quantization·parallelism 등 배포 구성이 바뀔 때마다 context length와 비교하라고 권고합니다.[11] pool이 작다면 context length를 낮추는 조치와 engine 수정을 함께 검토해야 하며, proxy의 제한만 믿어서는 안 됩니다.[11]
팀의 배포 gate에는 “최대 입력을 보냈더니 200이 나왔다” 대신 경계 길이의 요청이 의도한 오류 또는 올바른 답을 반환하는지를 넣는 편이 좋겠습니다. Cloudflare도 기존 시험이 상태 코드와 성능만 보고 출력의 내용·일관성을 확인하지 못했다고 인정했습니다.[11]
사건의 노출 경로를 막은 변경은 #36988이고, #38935는 이미 거부된 요청을 더 이른 입장 단계에서 정리하는 후속 변경입니다.[11][39] 후자의 PR은 스스로 correctness fix가 아니라 waste-and-latency fix라고 구분합니다.[39]
따라서 PR 제목이나 “abort 처리 추가”라는 요약만으로 보안 수정의 범위를 판단하면 안 됩니다. #38935는 Prefill만 거부하는 비대칭 구성에서는 상대 Decode의 대기를 즉시 끝내는 것까지 해결하지 않는다고 명시합니다.[39]
SGLang의 deferred release는 취소된 요청의 자원을 잠시 보유하고, 필요한 Prefill rank들의 drain acknowledgement 또는 설정된 timeout을 기다리는 방식입니다.[12] 다만 timeout 만료 자체는 쓰기가 실제로 끝났다는 증명이 아니며, 그 전에 outstanding write가 멈춘다는 조건이 필요합니다.[12]
운영에서는 release가 drained로 끝나는지, timeout에 자주 의존하는지 관찰해야 합니다.[12] Prefill·Decode의 acknowledgement 형식이 다른 혼합 버전도 handshake를 깨뜨려 timeout fallback을 늘릴 수 있습니다.[12]
Cloudflare는 8월 27일~9월 9일에 영향을 받은 응답이 155건이고, 모두 681,280 tokens를 넘는 요청에 대한 응답이었다고 보고합니다.[11] 회사는 9월 9일 서비스를 복구했으며 의도적 악용의 증거를 찾지 못했다고 밝혔습니다.[11]
이 수치는 Cloudflare의 자체 조사 범위입니다. “악용 증거 없음”을 “유출 없음”이나 “다른 사업자도 안전함”으로 바꿔 읽어서는 안 됩니다.
공개 issue #30233에는 긴 입력이 한 토큰 placeholder로 바뀐 뒤 Decode가 불완전한 cache로 무관한 출력을 생성한다는 사용자 보고와 재현 절차가 있습니다.[22] 명시된 환경은 SGLang 0.5.11·GLM-5.2-FP8·Mooncake입니다.[22]
이는 유사한 실패가 앞서 보고됐다는 근거이지, Cloudflare의 GLM-5.3 사고 규모를 외부에서 재현한 독립 시험은 아닙니다. 이번 글에서도 해당 GPU 시험을 직접 실행하지 않았습니다.
공식 문서는 PD 분리의 목적을 Prefill·Decode의 서로 다른 특성에 맞춘 최적화로 설명합니다.[40] SGLang의 사건 해설에 따르면 이 사건의 단일 노드 거부 경로는 오류를 반환했고, cross-engine transfer가 없는 단일 instance에는 해당 비동기 전송 race가 적용되지 않습니다.[12]
편집 의견: 아직 단계별 자원 분리가 필요하다는 측정 근거가 없는 작은 서비스라면, 단일 engine부터 시작하는 편이 운영 구성요소를 줄일 수 있습니다. 다만 특정 race가 없다는 사실을 모든 cache·메모리 문제가 없다는 보장으로 확대하면 안 됩니다.
PD를 유지한다면 pool·engine·router·transfer backend를 함께 관리해야 합니다.[11][12]
context length를 낮추는 조치는 수용 가능한 장문 입력 범위를 줄이는 선택이고, pool 확대만으로 모든 거부·전송 실패 경로의 수명 문제를 해결하지는 못합니다.[11][12]
비용 판단에서도 GPU 수만 볼 것이 아니라, 취소 시 보유되는 cache와 timeout 동안의 입장 지연을 포함하는 편이 좋겠습니다. SGLang은 pool이 부족할 때 deferred hold가 새 요청의 자원 할당을 늦출 수 있다고 설명합니다.[12]
Cloudflare의 권고는 SGLang v0.5.22 이상이지만, 버전 번호 하나를 모든 backend·실패 경로의 안전 인증으로 읽어서는 안 됩니다.[11][12] SGLang 공개표는 NIXL·MORI의 failed-send 처리에 별도 후속 변경 #45973을 기록합니다.[12]
또한 KV checksum·canary는 모든 모델 architecture를 지원하지 않으며, 옵션을 켰다는 사실과 실제 요청이 검증된다는 사실도 다릅니다.[11] 전송 checksum은 자원 소유권과 해제 수명 검증의 대체물이 아닙니다.[12]
이번 조사에서는 v0.5.22의 Mooncake 소스에서도 실패한 전송 뒤 drain acknowledgement를 막는 코드를 확인했습니다.[42] 이는 소스 열람 결과이며, 실제 배포에서 수정이 활성화됐다는 실행 검증은 아닙니다. 해당 릴리스는 Python 3.11 이상을 요구하므로 기존 운영 환경의 업그레이드 부담도 확인해야 합니다.[53]
💡 🔎 검증 노트: 공식 사고 보고서·SGLang 해설·cutoff 이전 기술 문서·PR·릴리스 소스·외부 사용자 issue를 대조했습니다. cutoff 뒤 갱신된 보고서의 문구를 10월 27일 09:00 당시 그대로였다고 주장하지 않습니다. 직접 설치·inference·exploit·부하 시험은 하지 않았으며, 관계 당사자의 보고와 독립적인 운영 재현은 구분했습니다.
모델을 바꿔도 남는 질문은 같습니다. “요청이 끝났는가?”가 아니라 “이 자원을 다른 요청에 넘겨도 안전하다는 근거가 있는가?”
[11] https://blog.cloudflare.com/kv-cache-exposure-remediation — Cloudflare — GLM-5.3 cross-request KV cache exposure (live report)
[12] https://www.lmsys.org/blog/2026-10-26-cf-security — SGLang Team — Security update: improving request isolation
[22] https://github.com/sgl-project/sglang/issues/30233 — SGLang issue #30233 — external user report (GLM-5.2-FP8)
[39] https://github.com/sgl-project/sglang/pull/38935 — SGLang PR #38935 — Do not admit intake-rejected requests to a PD handoff
[40] https://raw.githubusercontent.com/sgl-project/sglang/e732f5d41e9d279c1be7e5818ce9fd8bd425c0b2/docs/docs/advanced_features/pd_disaggregation.mdx — SGLang — PD Disaggregation (pre-cutoff commit e732f5d)
[42] https://raw.githubusercontent.com/sgl-project/sglang/v0.5.22/python/sglang/srt/disaggregation/mooncake/conn.py — SGLang v0.5.22 — Mooncake transfer and deferred ack source
[53] https://github.com/sgl-project/sglang/releases/tag/v0.5.22 — SGLang v0.5.22 — official release notes