GitHub Advisoryadvisory · crates.io

GHSA-9pj6-vhgr-3mwh RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service

HIGH Published 2026-09-16 Modified 2026-09-16 Official page ↗
Aliases
CVE-2026-63128
Ecosystem
crates.io
Packages
rmcp
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Weaknesses
CWE-400, CWE-401, CWE-772
Reviewed
2026-09-16

Affected packages

• crates.io rmcp: < 2.0.0 → fixed in 2.0.0

Description

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: ```rust } else { let (session_id, transport) = self .session_manager .create_session() // (★) .await .map_err(internal_error_response("create session"))?; // ...capture init params if a SessionStore is configured... if let ClientJsonRpcMessage::Request(req) = &mut message { let ClientRequest::InitializeRequest(init_req) = &req.request else { return Err(unexpected_message_response("initialize request")); // (A) }; validate_header_matches_init_body( // (B) &part.headers, init_req.params.protocol_version.as_str(), Some(req.id.clone()), )?; req.request.extensions_mut().insert(part); } else { return Err(unexpected_message_response("initialize request")); // (C) } let service = self .get_service() // (D) .map_err(internal_error_response("get service"))?; Self::spawn_session_worker( // (★★) self.session_manager.clone(), session_id.clone(), service, transport, None, ); // ...persist to external store, send response... } ``` Two facts make this unsafe: 1. `(★)` inserts a `LocalSessionHandle` into `LocalSessionManager.sessions` (a `tokio::sync::RwLock>`) and spawns a `LocalSessionWorker` task. 2. `(★★)` `spawn_session_worker` is the only code path in the entire transport (besides a client-initiated HTTP `DELETE` reaching `handle_delete`) that ever invokes `self.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` goes out of scope; its `_drop_guard` cancels the worker's `CancellationToken`. • The worker, which had been awaiting `event_rx.recv()`, exits within milliseconds via `WorkerQuitReason::Cancelled`. Its `event_rx` receiver is dropped. • `LocalSessionHandle.event_tx` (the `Sender` half of the same mpsc channel) is still alive because it is owned by the HashMap entry that nothing ever removes. The channel's `Inner` (sized to `channel_capacity = 16` by 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: ` 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): ```toml [package] name = "rmcp_leak_repro" version = "0.0.1" edition = "2021" publish = false [dependencies] rmcp = { version = "1.7.0", default-features = false, features = [ "server", "transport-streamable-http-server", ] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } tokio-util = { version = "0.7" } axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } anyhow = "1" [workspace] ``` `src/main.rs` (paste verbatim): ```rust //! Minimal MCP Streamable HTTP server that prints the size of the //! LocalSessionManager.sessions HashMap once a second so the leak is //! observable from stdout. use std::sync::Arc; use rmcp::{ ErrorData, RoleServer, ServerHandler, model::{Implementation, InitializeRequestParams, InitializeResult, ServerCapabilities}, service::RequestContext, transport::{ StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, }, }; const BIND_ADDRESS: &str = "127.0.0.1:8000"; [derive(Clone, Default)] struct MinimalServer; impl ServerHandler for MinimalServer { async fn initialize( &self, _request: InitializeRequestParams, _cx: RequestContext, ) -> Result { Ok(InitializeResult::new(ServerCapabilities::builder().build()) .with_server_info(Implementation::new("rmcp-leak-repro", "0.0.1"))) } } [tokio::main] async fn main() -> anyhow::Result { let ct = tokio_util::sync::CancellationToken::new(); let manager: Arc = Arc::new(LocalSessionManager::default()); // Reporter — prints sessions.len() every second. { let manager = manager.clone(); let ct = ct.clone(); tokio::spawn(async move { loop { tokio::select! { _ = ct.cancelled() => break, _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => { let n = manager.sessions.read().await.len(); println!("[count] active_sessions={n}"); } } } }); } let service = StreamableHttpService::new( || Ok(MinimalServer::default()), manager.clone(), StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), ); let router = axum::Router::new().nest_service("/mcp", service); let tcp_listener = tokio::net::TcpListener::bind(BIND_ADDRESS).await?; println!("[server] listening on http://{BIND_ADDRESS}/mcp"); let _ = axum::serve(tcp_listener, router) .with_graceful_shutdown(async move { tokio::signal::ctrl_c().await.ok(); ct.cancel(); }) .await; Ok(()) } ``` Start it: ```bash cargo run --release ``` Initial output: ``` [server] listening on http://127.0.0.1:8000/mcp [count] active_sessions=0 [count] active_sessions=0 [count] active_sessions=0 ``` Step 2 — attacker `attack.py` (paste verbatim — Pyth…

Related

Alias · CVE (1)

  • CVE-2026-63128 RMCP is an official Rust SDK for the Model Context Protocol. vulnerability

Weakness · CWE (3)

  • CWE-400 Uncontrolled Resource Consumption weakness
  • CWE-401 Missing Release of Memory after Effective Lifetime weakness
  • CWE-772 Missing Release of Resource after Effective Lifetime weakness

References