MCP 스펙 자체의 state-handle 수정 사항은 우회 가능합니다
요약
MCP(Model Context Protocol) 스펙의 상태 핸들(state-handle) 관리 방식에서 발생할 수 있는 보안 취약점과 우회 가능성을 분석합니다. 단순 문자열 연결 방식의 키 지정이 IDOR(부적절한 직접 객체 참조) 공격에 노출될 수 있음을 실험으로 증명합니다.
핵심 포인트
- MCP 프로토콜은 세션이 없는 상태 없는(stateless) 방식으로 변경됨
- 상태 핸들 하이재킹을 방지하기 위해 서버 측 바인딩 권장
- 단순 문자열 연결 방식의 키 지정은 구분자 공격에 취약함
- 구조화된 주체 ID를 사용하여 경계 모호성을 해결해야 함
참고. 이것은 애플리케이션 권한 부여(application-authorization)에 관한 발견 사항이며, MCP 프로토콜 자체의 취약점은 아닙니다. 실험은 완전히 인프로세스(in-process)로 실행됩니다: 소켓, DNS, 클라우드 계정, 실제 타겟이 없으며, 실제 OAuth 흐름 대신 피스처 토큰(fixture tokens)을 사용합니다. 카테고리는 스펙이 스스로 명명한 것과 같습니다.
요약 (TL;DR)
2026-07-28 개정판에서는 MCP 프로토콜 세션이 제거되었습니다. 동일한 릴리스에서 보안 모범 사례(Security Best Practices) 문서는 **상태 핸들 하이재킹 (State Handle Hijacking)**이라는 섹션을 추가하고 규범적 요구 사항 — "MCP 서버는 상태 핸들(state handle)을 소유하고 있다는 사실을 인증으로 취급해서는 안 된다(MUST NOT)" — 및 이를 준수하기 위한 권장 사항을 추가했습니다: 핸들을 서버 측에서 바인딩해야 하며, _"예를 들어 저장된 상태를 <user_id>:<handle> 형식으로 키를 지정하는 방식"_을 권장합니다.
스펙은 테스트 없이 요구 사항을 배포했습니다. 제가 테스트를 작성했습니다. 권장된 키 형식은 테스트를 통과하지 못합니다. 문자열 연결(string concatenation)은 모호하며, 구조화된 주체 ID(structured principal id)를 사용할 경우 공격자가 구분자(delimiter)를 한 세그먼트 이동시켜 소유권 확인이 실행되고 통과되는 와중에 다른 사람의 레코드에 도달할 수 있습니다. 13개 중 13개의 테스트를 통과했으며, 소스 해시가 포함된 증거 매니페스트가 준비되어 있습니다.
실제로 변경된 내용
initialize 핸드셰이크나 Mcp-Session-Id가 없습니다. 버전과 기능(capabilities)은 _meta를 통해 요청마다 전달되며, 각 요청은 개별적으로 처리됩니다.
이로 인해 _프로토콜_은 상태가 없는(stateless) 방식이 됩니다. 그렇다고 해서 인증, 비즈니스 상태, 작업 중복 제거(operation deduplication) 또는 이벤트 상관관계(event correlation)가 상태가 없어지는 것은 아닙니다. 이것들은 사라진 것이 아니라 소유자가 변경되었으며, 새로운 소유자는 바로 여러분의 애플리케이션 코드입니다.
실험 (The lab)
클라이언트와 MCP 핸들러는 제어된 fetch를 통해 통신합니다. 피스처 베어러 토큰(Fixture bearer tokens)은 검증된 sub 클레임(claim)을 통해 서로 다른 주체(principals)에 매핑됩니다. 핸들은 항상 crypto.randomUUID()입니다. 유일한 변수는 레코드가 키 지정되는 방식입니다:
const KEY_STRATEGIES = {
vulnerable: (owner, handle) => handle,
naive: (owner, handle) => `${owner}:${handle}`,
...
동일한 토큰, 동일한 도구(tools), 동일한 UUID 생성기, 동일한 입력값. 오직 키(key)만 변경됩니다. 따라서 IDOR(Insecure Direct Object Reference)가 발생할 때마다 흔히 쓰이는 변명인 '취약하거나 추측 가능한 식별자' 때문이라고 탓할 수 있는 것이 아무것도 없습니다.
vulnerable 버전은 예상대로 실패합니다: 두 번째 인증된 주체(principal)가 첫 번째 주체에게 반환된 핸들(handle)을 제시하고, 해당 주체의 비공개 노트를 읽고 작업을 완료합니다. 핸들에 대한 지식이 곧 소지자 자격 증명(bearer credential)이 되어버린 것입니다.
${owner}:${handle} 방식이 실패하는 이유
연결(Concatenation)은 각 부분 사이의 경계를 보존하지 못합니다:
("a", "b:c") → "a:b:c"
("a:b", "c") → "a:b:c"
핸들은 일반적인 도구 인자(tool argument)로 전달되므로, 호출자가 문자열의 절반을 완전히 제어할 수 있습니다. 만약 주체 ID(principal id)에 구분자(delimiter)가 포함될 수 있다면, 경계는 이동 가능해집니다:
피해자(victim) `tenant-a:svc` + 핸들 `H` → 키 "tenant-a:svc:H"
공격자(attacker) `tenant-a` + 핸들 `svc:H` → 키 "tenant-a:svc:H" ← 동일한 키
공격자는 실제로 다른 주체로서 인증되었습니다. 모든 호출에서 소유권(Ownership)은 확인됩니다. 다만 그 확인 과정이 잘못된 질문에 답하고 있을 뿐입니다.
구조화된 주체 ID는 교과서적인 호기심 대상이 아닙니다. 멀티 테넌트(multi-tenant) 발행자들은 이를 생성하며, API 게이트웨이 JWT 인가자(authorizer) 뒤에서 자연스럽게 구축하게 될 표준적인 iss + sub 형태도 마찬가지입니다.
이를 명확히 보여주는 단언(assertion)은 다음과 같습니다:
const smuggled = `svc:${created.handle}`;
const attempt = await attacker.callTool({
name: "read_scan",
...
두 변형 모두 네트워크 전송 시 바이트(bytes)는 동일합니다. 오직 서버 측의 키만 다를 뿐입니다.
해결책
쌍(pair)을 단순히 _결합(joining)_하는 것을 멈추고, _인코딩(encoding)_을 시작하십시오: JSON.stringify([owner, handle])를 사용하거나, 길이 접두사(length-prefixed) 키를 사용하거나, 저장소에 두 개의 별도 속성을 두어 구조적으로 구분되도록 해야 합니다. DynamoDB의 파티션 키(partition key) + 정렬 키(sort key) 조합은 템플릿 리터럴(template literal)이 아닌 데이터 저장소에 의해 경계가 강제되므로, 이를 별도의 노력 없이 올바르게 처리합니다.
이 방법들 중 어느 것도 연결(concatenation) 방식보다 느리거나, 길거나, 어렵지 않습니다.
스펙(spec)에 대해 공정하게 말하자면, 스펙은 “예를 들어(for example)”라고 기술하고 있으므로, 이는 강제 사항이라기보다 예시를 보여주는 것에 가깝습니다. 하지만 보안 모범 사례(security best-practices) 문서 안에 있는 예시는 예시로 읽히지 않습니다. 그대로 복사되어, 동일한 위치에 동일한 구분자(delimiter)를 가진 채로 준수하는 것처럼 보이면서 프로덕션(production) 환경에 도달하게 됩니다.
에이전트 시스템(agentic systems)에 특화된 부분
노출에 대해, 스펙은 공격자가 핸들(handle)을 “획득하거나 추측한다(obtains or guesses)”라고 명시합니다. 전통적인 웹 애플리케이션에서 이 동사는 실질적인 장벽이 됩니다.
에이전트 시스템(agentic system)에서 적절한 동사는 스펙이 명시하지 않은 세 번째 동사인 **수집(collect)**입니다.
tool result
└─ opaque handle
├─ model context / transcript
...
이 구성 요소 중 어느 것도 보안 경계(security boundary)로 설계되지 않았으며, 이들 모두는 문자열을 다시 읽을 수 있습니다. 핵심은 모든 오케스트레이터(orchestrator)가 이 값들을 유출한다는 것이 아닙니다. 백엔드가 핸들(handle)만으로 권한을 부여한다면, 이를 읽고 재전송(replay)할 수 있는 모든 구성 요소가 보안 경계 안으로 들어오게 된다는 점입니다. 여기에는 지난주에 디버깅을 위해 추가한 구성 요소들도 포함됩니다.
전파(propagation)를 줄이는 것이 도움이 됩니다. 목적지에서 소유권(ownership)을 검증하는 것은 타협할 수 없는 사항입니다.
범위 (Scope)
하나의 구현체, 고정된(pinned) 하나의 SDK 버전, 실제 OAuth 흐름 대신 픽스처 토큰(fixture tokens), 데이터 스토어(datastore) 대신 프로세스 내 저장소(in-process storage), LLM 없음, AWS 계정 없음. 구분자 모호성(delimiter ambiguity)은 특정 SDK의 속성이 아니라 키 형식(key format)의 속성입니다. 런타임(Runtime), 커밋(commit) 및 소스 해시(source hashes)는 산문(prose)이 아닌 증거 매니페스트(evidence manifest)에 존재하므로, 두 가지가 서로 어긋날 수 없습니다.
이탈리아어 및 영어로 작성된 전체 보고서, 재현 가능한 실험실, 정규화된 TAP 출력 및 기본 자료:
👉 https://paolocanzo.github.io/mcp-2026-07-28-stateless-security/
면책 조항: 이 포스트는 기본 자료(MCP Specification 2026-07-28, MCP Security Best Practices, TypeScript SDK v2 migration guide, AWS API Gateway 및 DynamoDB 문서)를 바탕으로 AI의 도움을 받아 작성되었습니다. 편집적 프레임워크(Editorial framing)는 작성자의 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기