본문 바로가기
CVE 취약점

CVE-2026-42559: rmcp Streamable HTTP 서버의 DNS Rebinding 취약점 분석

by WhiteGuidance 2026. 9. 20.
반응형

 

취약점 개요


항목 내용
CVE ID CVE-2026-42559
컴포넌트 rmcp (Model Context Protocol 공식 Rust SDK)
취약 위치 crates/rmcp/src/transport/streamable_http_server/tower.rs
취약 버전 rmcp < 1.4.0
패치 버전 rmcp >= 1.4.0 (커밋 8e22aa2, PR #764)
CVSS 3.1 8.8 (High) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CWE CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS Resolution)
취약점 유형 DNS Rebinding → 로컬 서비스 접근

rmcp는 Anthropic이 만든 Model Context Protocol(MCP)의 공식 Rust SDK로, LLM 클라이언트와 도구(tool)를 제공하는 MCP 서버 사이의 통신 계층을 구현한다. 이 SDK로 만든 MCP 서버는 대부분 개발자의 로컬 머신(127.0.0.1)에서 파일시스템·쉘·브라우저 제어 같은 강력한 툴을 노출한 채로 떠 있는데, 문제는 이 서버의 Streamable HTTP transport가 들어오는 요청의 Host 헤더를 전혀 검증하지 않았다는 점이다. 이 한 줄의 누락이 DNS Rebinding 공격과 결합되면서, 피해자가 악성 웹페이지를 열어보기만 해도 로컬 MCP 서버의 툴을 임의로 호출할 수 있는 통로가 열린다.


DNS Rebinding 공격 원리

브라우저의 동일 출처 정책(SOP)은 도메인 이름을 기준으로 출처를 판단하고, 요청을 실제로 어디로 보낼지는 DNS가 그 순간에 반환하는 IP에 맡긴다. DNS Rebinding은 이 틈을 노린다.

  1. 공격자가 자신이 소유한 도메인(attacker.example)의 DNS TTL을 매우 짧게(예: 1초) 설정해둔다.
  2. 피해자가 해당 도메인으로 접속하면, 처음엔 정상적인 공인 IP를 반환해 페이지를 정상적으로 로드시킨다.
  3. 페이지 내 JavaScript가 TTL 만료 후 같은 도메인으로 다시 요청을 보내면, 이번엔 DNS 응답을 127.0.0.1(또는 사설 대역)로 바꿔치기한다.
  4. 브라우저 입장에서는 여전히 "같은 출처(attacker.example)"로 보이기 때문에 SOP를 통과하며, 실제 TCP 연결은 피해자 로컬 머신으로 맺어진다.
  5. 이때 HTTP 요청의 Host 헤더는 여전히 attacker.example로 전송되지만, 수신 서버는 이 로컬 머신 — 즉 rmcp로 띄운 MCP 서버 — 이다.

여기서 서버가 Host 헤더 값을 확인해서 "내가 알고 있는 이름(localhost, 127.0.0.1 등)이 아니면 거부"하는 로직만 있었다면 이 공격은 5번 단계에서 막힌다. 그런데 패치 전 rmcp에는 그 로직 자체가 없었다.

 


패치 전: 무엇이 빠져 있었나

GitHub 보안 권고(GHSA-89vp-x53w-74fx)에 따르면, 1.4.0 이전 버전의 Streamable HTTP 서버는 요청이 들어오면 프로토콜 버전 검사(MCP-Protocol-Version), 세션 ID 검사, JSON-RPC 파싱으로 바로 진입했고 Host/Origin 헤더를 대조하는 단계 자체가 파이프라인에 존재하지 않았다. 개념적으로 표현하면 아래와 같다.

 

즉 서버는 요청이 "누구에게서 왔는가"가 아니라 "MCP 프로토콜 규격에 맞는가"만 검사했다. 요청을 보낸 소켓이 로컬(127.0.0.1)에서 왔다는 사실만으로 신뢰할 수 있는 클라이언트라고 암묵적으로 가정한 셈인데, DNS Rebinding은 정확히 이 가정 — "루프백으로 온 요청 = 신뢰 가능"과 "Host 헤더 값 = 실제 통신 상대" 사이의 괴리 — 을 깨뜨린다.

영향 범위는 보고서에 명시된 대로 상당히 넓다.

  • MCP 서버가 노출한 모든 툴을 열거(tools/list)하고 임의로 호출(tools/call)
  • 세션을 통해 접근 가능한 리소스·프롬프트·상태 조회
  • 파일 쓰기, 쉘 명령 실행, 외부 API 호출 등 툴이 가진 부작용을 그대로 트리거

MCP 서버는 보통 사용자 권한으로 실행되며 파일시스템·쉘·브라우저 제어 같은 개발 도구를 노출하는 경우가 많기 때문에, 실질적인 영향은 피해자 PC에서의 임의 코드 실행까지 이어질 수 있다.


패치 이후: 실제 소스코드 분석

패치는 크게 세 가지로 구성된다. ① 기본값으로 루프백 호스트만 허용하는 allowlist 도입, ② 모든 요청이 거치는 validate_dns_rebinding_headers 검증 단계 삽입, ③ Origin 헤더에 대한 부가적(defense-in-depth) 검증. 실제 배포된 tower.rs 소스를 한 단계씩 뜯어보자.

1) 기본 allowlist: StreamableHttpServerConfig

 

핵심은 Default 구현이다. 별도 설정 없이 StreamableHttpServerConfig::default()를 쓰기만 해도 allowed_hostslocalhost, 127.0.0.1, ::1 세 개로 채워진다. 즉 "안전하지 않은 기본값(insecure by default)"에서 "안전한 기본값(secure by default)"으로 전환한 것이 이번 패치의 설계 철학이다. 공인 서버로 배포하려는 개발자는 with_allowed_hosts(["mcp.example.com"])로 명시적으로 넓혀야 하고, 반대로 검증을 끄고 싶다면 disable_allowed_hosts()를 호출해야 한다 — 기본 동작이 아니라 의식적인 선택을 거치도록 강제한 것이다.

 

allowed_hosts가 빈 벡터가 되면 뒤에서 살펴볼 host_is_allowed가 "비어 있으면 전부 허용"으로 처리하므로, disable_allowed_hosts()는 문자 그대로 검증 자체를 무력화한다.

2) 요청 진입점에서의 강제 검사

 

패치의 가장 중요한 지점은 위치 선정이다. validate_dns_rebinding_headers가 handle() 최상단, 즉 메서드 분기(match method)와 세션 조회보다 먼저 실행된다. 검증에 실패하면 세션 상태를 건드리거나 JSON-RPC 바디를 파싱하기 전에 즉시 응답을 반환해버리므로, 공격 표면이 "요청이 서버 내부 로직에 도달하기 전" 단계로 확 줄어든다.

3) Host 헤더 파싱 — parse_host_header

여기서 눈여겨볼 부분은 HTTP/2 대응이다. HTTP/1.1은 Host: 헤더로 대상을 지정하지만, HTTP/2는 요청 라인의 :authority 의사 헤더로 이를 대체한다. 일부 미들웨어(예: axum::Router::nest)가 라우팅 과정에서 hyper가 합성해준 Host 헤더를 지워버릴 수 있기 때문에, 헤더가 없을 경우 request.uri()의 authority를 폴백으로 사용한다. 즉 "Host 헤더가 없으면 통과"가 아니라 "Host 헤더가 없으면 URI의 authority라도 반드시 확인한다"는 방어적 설계다.

4) 호스트 정규화 — 대소문자·IPv6 괄호 처리

 

DNS 이름은 대소문자를 구분하지 않고(Localhost localhost는 같은 호스트), IPv6 리터럴은 URI 상에서 [::1]처럼 대괄호로 감싸 표기된다. 이 두 가지를 정규화해두지 않으면 Host: LOCALHOSTHost: [::1] 같은 변형으로 allowlist 비교를 우회당할 수 있다. normalize_host는 이 두 케이스를 한 번에 처리해서, 이후 비교 로직이 순수 문자열 == 비교만으로 안전하게 동작하도록 만든다.

5) allowlist 매칭 — host_is_allowed

 

설정값("example.com", "example.com:8080" 등)을 Authority로 파싱해서 호스트/포트를 분리한 뒤 비교한다. 포트를 지정하지 않은 allowlist 항목("localhost")은 어떤 포트로 들어오든 매치되고, 포트까지 지정한 항목("example.com:8080")은 정확히 그 포트만 허용한다. 이 유연성 덕분에 개발 중에는 localhost(포트 무관)로 넓게 열어두고, 운영 환경에서는 example.com:443처럼 좁혀 쓸 수 있다.

6) 최종 검증 함수 — validate_dns_rebinding_headers

 

이 함수 하나가 패치의 핵심이다. Host 헤더를 파싱해 allowlist에 없으면 즉시 403 Forbidden을 반환하고, tracing::warn!으로 DNS Rebinding 시도 가능성을 로그에 남긴다. 앞서 다이어그램의 5번 단계에서 Host: attacker.example로 도착한 요청은 이 지점에서 걸러진다 — attacker.example은 기본 allowlist(localhost, 127.0.0.1, ::1) 어디에도 속하지 않기 때문이다.

7) Origin 헤더 검증 — 심층 방어

 

권고문에 명시된 대로, 이번 CVE의 근본 원인은 어디까지나 Host 헤더 미검증이며 Origin 검증은 필수가 아니다 — 브라우저는 리바인딩된 대상에게 보내는 요청의 Host 헤더 자체를 위조할 수 없으므로, Host allowlist만으로 이번 공격은 차단된다. 그럼에도 Origin 검증을 추가한 이유는 심층 방어(defense-in-depth) 때문이다. 기본값은 allowed_origins가 비어 있으면 검증을 건너뛰도록(하위 호환성 유지) 설계되어 있고, enforce_origin_validation()을 호출하면 빈 리스트 상태에서도 Origin 헤더가 존재하는 모든 요청을 거부하는 엄격 모드로 전환할 수 있다.


대응 방안

  • rmcp 1.4.0 이상으로 업그레이드한다. 기본 설정만으로도 루프백 이외의 Host를 가진 요청은 차단된다.
  • 공인 도메인으로 MCP 서버를 배포한다면 StreamableHttpServerConfig::default().with_allowed_hosts(["mcp.example.com"])처럼 실제 호스트명을 명시적으로 등록해야 한다.
  • 즉시 업그레이드가 어렵다면, Host 헤더가 예상한 값이 아닌 요청을 거부하도록 설정한 리버스 프록시(nginx, Caddy 등)를 앞단에 두고, MCP 서버를 0.0.0.0에 직접 바인딩하지 않는다.
  • disable_allowed_hosts()는 이번 취약점이 패치되기 이전 상태로 되돌리는 옵션이므로, 업스트림에 별도의 Host 검증 프록시가 없다면 사용하지 않는다.
반응형