Résumé
RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service
Détails de l’avis
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:
} 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:
(★)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):
[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):
//! 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<RoleServer>,
) -> Result<InitializeResult, ErrorData> {
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<LocalSessionManager> = 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:
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
Références
- https://github.com/advisories/GHSA-9pj6-vhgr-3mwh
- https://github.com/modelcontextprotocol/rust-sdk/security/advisories/GHSA-9pj6-vhgr-3mwh
- https://nvd.nist.gov/vuln/detail/CVE-2026-63128
- https://github.com/modelcontextprotocol/rust-sdk/pull/934
- https://github.com/modelcontextprotocol/rust-sdk/commit/dfa7fd6f9309deab60bea230b041be9a3fcda846
- https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v2.0.0
Vulnérabilités liées
Tout Supply chain →- MEDIUMCVE-2026-59951
MCP Python SDK: Unbounded Session Accumulation in StreamableHTTP Transport Enables Resource Exhaustion
- MEDIUMCVE-2026-107834
Coraza: Resource exhaustion via deferred file handle accumulation in multipart body processor
- HIGHCVE-2026-69208
Http4s: DigestAuth nonce map grows unbounded
- MEDIUMCVE-2026-95838
containerd: Unpack DoS via repeated opaque whiteouts
- MEDIUMCVE-2026-100350
kin-openapi deepObject query decoding repeatedly compiles an invariant regular expression
- MEDIUMCVE-2026-104150
AdonisJS HTTP Server: Uncontrolled Resource Consumption and Inefficient Regular Expression Complexity