CVE-2026-63119Medium· 6.2▾ SunlitMCP Ruby SDK: Unbounded line buffer in stdio transports leads to memory exhaustion (DoS)
▾ Sunlit zone — Low / medium · no exploitation signal
impact 34.1 · likelihood 0 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
Exploit-prediction probability, daily snapshots since Jul 30.
Disclosure to exploitation, from the record and what we observed since indexing it.
Disclosed via GHSA
0.1%
0.1% → 0.2%
The stdio transports in MCP::Server::Transports::StdioTransport and MCP::Client::Stdio read newline-delimited JSON-RPC frames using IO#gets with no limit argument. CRuby's IO#gets with no limit reads from the current position until the next separator (\n) with no upper bound on the returned string length. A peer that streams bytes without ever emitting a newline causes gets to accumulate the entire stream in a single Ruby String until the process is killed by the operating-system OOM killer.
This is the same vulnerability class tracked in sibling MCP SDKs as GHSA-74gp-qhv5-v493 (Kotlin), GHSA-wqgc-pwpr-pq7r (TypeScript), GHSA-655q-2283-6jgj (Python), and others. It was identified during a cross-SDK audit; ruby-sdk had no prior report.
Verified at main @ cf44475c.
lib/mcp/server/transports/stdio_transport.rb# line 23
while @open && (line = $stdin.gets) # <-- no limit argument
response = @session.handle_json(line.strip)
...
# line 76
while @open && (line = $stdin.gets) # <-- no limit argument
begin
parsed = JSON.parse(line.strip, symbolize_names: true)
$stdin is the raw process global (only set_encoding is applied at line 16); there is no wrapper imposing a length limit.
lib/mcp/client/stdio.rb# line 150
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(spawn_env, @command, *@args)
# line 228
line = @stdout.gets # <-- no limit argument
raise_connection_error!(method, params) if line.nil?
parsed = JSON.parse(line.strip)
@stdout is the raw IO returned by Open3.popen3. The @read_timeout guard at line 227 (wait_for_readable!) only gates the IO.select before gets is invoked; once bytes are flowing, gets blocks indefinitely accumulating into one string.
Denial of service via memory exhaustion. A peer that controls the byte stream delivered to the stdio transport can grow a single Ruby String until the process exhausts available memory.
Threat-model caveat (important): In the default stdio deployment, the peer process already holds local execution privileges equal to or greater than the victim:
StdioTransport#open), the writer to $stdin is the parent process that spawned the server, which already holds Process.kill, environment control, and filesystem access over the child. This direction is a robustness defect rather than a security boundary in typical deployments.Client::Stdio#read_response), the writer is the third-party MCP server binary the host application chose to spawn via Open3.popen3. In an unsandboxed deployment that binary already has local code execution as the host user.This issue is primarily a security concern for:
gets lets a memory-capped sandboxed process exhaust the unconstrained host;The bug fires before the JSON-RPC initialize handshake, since gets cannot return until \n arrives.
# poc_ruby_stdio_oom.rb — drives the real client transport against a producer that never emits \n
require "mcp/client/stdio"
# `yes` writes "y\n" — instead use a producer that never sends \n:
producer = %q{ruby -e 'STDOUT.sync=true; loop { print "A" * 65536 }'}
client = MCP::Client::Stdio.new(command: "bash", args: ["-c", producer])
client.start
# any request triggers read_response → @stdout.gets → unbounded String growth
client.send_request(method: "initialize", params: {})
Observed: process RSS grows linearly with bytes produced; gets never returns; process is OOM-killed.
IO#gets accepts a second limit argument. Apply a configurable maximum line length (default suggested: 4 MiB — large enough for any realistic JSON-RPC frame including base64-embedded images) at all three call sites, and treat an over-limit line as a transport error that closes the connection:
MAX_LINE_BYTES = 4 * 1024 * 1024
while @open && (line = $stdin.gets("\n", MAX_LINE_BYTES))
unless line.end_with?("\n")
# gets returned because the limit was hit, not because a newline arrived
raise MCP::TransportError, "stdio frame exceeds #{MAX_LINE_BYTES} bytes without newline"
end
...
end
Apply the same pattern at stdio_transport.rb:76 and client/stdio.rb:228. Expose MAX_LINE_BYTES as a constructor option on both transports for callers with legitimate large-frame needs.
Identified during a cross-SDK audit of the stdio unbounded-buffer vulnerability class, prompted by GHSA-74gp-qhv5-v493 (reporter: tonghuaroot).
mcp <= 0.22.0Upgrade to a patched release:
mcp 0.23.0Connected by shared product, vendor, weakness, or advisory.
CVE-2026-67430Medium· 5.3MCP Ruby SDK: Unbounded session retention in StreamableHTTPTransport allows memory exhaustion via initialize flood
CVE-2026-67432High· 7.5MCP Ruby SDK: Unbounded JSON-RPC request body causes uncontrolled memory allocation in StreamableHTTPTransport
CVE-2026-63118MediumMCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection
CVE-2026-67431HighMCP Ruby SDK: Ruby SSE Session Poisoning
CVE-2026-53965HighThe MCP PHP SDK (Composer package mcp/sdk) is the official Model Context Protocol SDK for PHP
CVE-2024-12254High· 7.5Starting in Python 3.12.0, the asyncio._SelectorSocketTransport.writelines() method would not "pause" writing and signal to the Protocol to drain the buffer to the wire once the write buffer reached the "high-water mark"