Skip to content
Mcp Kotlin Sdk Websocket Frame Allocation Dos

Mcp Kotlin Sdk Websocket Frame Allocation Dos

hackmd.io • October 11, 2026

# MCP Kotlin SDK: Application.mcpWebSocket allocates an attacker-sized WebSocket frame buffer from the frame header

![type]( ![cwe]( ![component]( ![severity]( ![status](

**Reported by:** HaiND (Nguyen Dinh Hai), Post and Telecommunication Institute of Technology

**Project:** [modelcontextprotocol/kotlin-sdk]( official MCP Kotlin SDK, server WebSocket transport

**Affected:** release 0.15.0 (commit `76b8e7d63aac39fe41e03684e3b91a6c3724a8da`, Ktor 3.5.1) and current main (commit `9f4cca65db7a8cc145d92c0d9666e568bca66430`, Ktor 3.5.2). The helper and transport predate 0.15.0, so earlier releases are likely affected and were not surveyed.

**Class:** CWE-770 Allocation of Resources Without Limits or Throttling, CWE-789 Memory Allocation with Excessive Size Value

`Application.mcpWebSocket` is the one-call server helper the SDK offers for serving MCP over WebSocket. It installs the Ktor WebSockets plugin with no configuration, so the plugin keeps Ktor's default `maxFrameSize` of `Long.MAX_VALUE`. When a WebSocket frame arrives, the Ktor reader checks the length declared in the frame header against that limit and, finding it unbounded, allocates a single on-heap `ByteBuffer` of the declared size before the body is received. The only hard ceiling is `Int.MAX_VALUE`, 2 GiB per frame. The MCP transport that sits on top reads the whole frame into a String with `readText()` and adds no size check of its own, and the server transport authenticates nothing beyond the `mcp` subprotocol string. A client that can reach an endpoint registered with this helper can send one small frame header that declares a near 2 GiB length and force that allocation in the server process, without sending the declared bytes. The SDK already bounds its HTTP and SSE path at 4 MiB with a dedicated reader, so the WebSocket path is the one input route that the SDK's own limit work did not reach. Impact is availability only and is stated from source, no runtime crash threshold was measured.

The chain has four links. The first is in the SDK, the two are in the Ktor dependency the SDK pins, and the last is back in the SDK transport.

### 1. The server helper installs WebSockets with no frame limit

`kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt:45-63`

public fun Application.mcpWebSocket(block: () -> Server) {

public fun Application.mcpWebSocket(path: String, block: () -> Server) {

`install(WebSockets)` is called with no trailing configuration block. The plugin keeps every default. There is no `maxFrameSize` set here, and there is no other place in the SDK that sets it for this path.

The sibling helpers at `WebSocketMcpKtorServerExtensions.kt:25-39` take a different route.

public fun Route.mcpWebSocket(block: () -> Server) {

`Route.mcpWebSocket` does not install the plugin. A host that mounts the route this way writes its own `install(WebSockets) { ... }` and can set a smaller `maxFrameSize` there. The weakness is specific to the `Application.mcpWebSocket` form, which is the form the SDK presents as the quick way to stand up a server.

### 2. The Ktor default frame limit is the maximum long

`ktor-server/ktor-server-plugins/ktor-server-websockets/common/src/io/ktor/server/websocket/WebSockets.kt:104` (Ktor 3.5.1 and 3.5.2)

public var maxFrameSize: Long = Long.MAX_VALUE

With no override, `maxFrameSize` is `Long.MAX_VALUE`. This value is passed into the raw WebSocket transport when the connection is upgraded, and from there into the frame reader.

### 3. The reader allocates the header-declared length as one buffer

`ktor-shared/ktor-websockets/jvm/src/io/ktor/websocket/WebSocketReader.kt:99-105`

if (frameParser.length > Int.MAX_VALUE || frameParser.length > maxFrameSize) {

throw FrameTooBigException(frameParser.length)

collector.start(frameParser.length.toInt(), buffer)

`frameParser.length` is the payload length taken from the WebSocket frame header. The guard rejects a frame only when that length is above `Int.MAX_VALUE` or above `maxFrameSize`. With `maxFrameSize = Long.MAX_VALUE`, the binding ceiling is `Int.MAX_VALUE`, which is 2147483647 bytes. Any declared length at or below that passes.

`frameParser.bodyReady` is set once the header is parsed, which is before the body is received in full. So the length is known, and acted on, from the header.

`SimpleFrameCollector.start` performs the allocation.

`ktor-shared/ktor-websockets/jvm/src/io/ktor/websocket/SimpleFrameCollector.kt:18-28`

public fun start(length: Int, bb: ByteBuffer) {

require(remaining == 0) { throw IllegalStateException("remaining should be 0") }

if (buffer == null || buffer!!.capacity() (message.readText())

There is no byte count check before `readText()`. The server transport's only entry gate is a subprotocol string comparison.

`kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpServerTransport.kt:16-23`

public class WebSocketMcpServerTransport(override val session: WebSocketServerSession) : WebSocketMcpTransport() {

override suspend fun initializeSession() {

val subprotocol = session.call.request.headers[HttpHeaders.SecWebSocketProtocol]

error("Invalid subprotocol: $subprotocol, expected $MCP_SUBPROTOCOL")

Any authentication is left to host Ktor middleware. The SDK transport does not require a token or a session identity.

### The SDK knows how to bound this, on the other transports

The HTTP and SSE server path reads the request body through a dedicated bounded reader.

`kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt:11,25-33`

internal const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024

internal suspend fun ApplicationCall.receiveTextWithLimit(maxBytes: Long): String {

val readLimit = if (maxBytes == Long.MAX_VALUE) maxBytes else maxBytes + 1

val read = receiveChannel().readRemaining(readLimit).transferTo(buffer)

throw RequestBodyTooLargeException(maxBytes)

The on this reader states that an oversized or unframed body cannot exhaust memory. The WebSocket server helper has no matching bound, which is why this is an inconsistency inside the SDK rather than an intended choice.

participant R as WebSocketReader and collector

A->>K: WebSocket upgrade with subprotocol mcp

K->>M: subprotocol check passes, session open

A->>R: one text frame header, declared length near 2 GiB, no body sent

Note over R: maxFrameSize is Long.MAX_VALUE so only the Int.MAX_VALUE ceiling applies

R->>R: collector.start allocates ByteBuffer of the declared length

Note over R: large on-heap allocation driven by the header

R-->>A: connection stalls or reader throws OutOfMemoryError

Note over M,R: repeat on more connections to raise memory pressure

This is the smallest reproduction. It was not executed here, the review scope was static source only. The steps are written so a maintainer can run them against an isolated local server.

1. Stand up a minimal server that uses the affected helper.

import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket

// plus the usual Server(...) construction from the SDK quickstart

mcpWebSocket { buildMcpServer() } // Application.mcpWebSocket, no maxFrameSize

Run it with a small heap to make the effect obvious, for example `-Xmx256m`.

2. Open a WebSocket connection and complete the MCP handshake by requesting the `mcp` subprotocol.

Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

3. Send one masked text frame whose header declares a near 2 GiB payload, then send nothing more. A client frame uses the 64-bit extended length form. The header bytes are.

0x81 FIN set, opcode 0x1 text

0xFF MASK set, length marker 127 so a 64-bit length follows

0x00 0x00 0x00 0x00 0x7F 0xFF 0xFF 0xF0 payload length 2147483632

0xAA 0xBB 0xCC 0xDD 4 byte masking key

The raw-socket shape in Python, for a local test only.

s = socket.create_connection(("127.0.0.1", 8080))

# ... perform the HTTP Upgrade handshake with Sec-WebSocket-Protocol: mcp ...

header = b"\x81\xff" + struct.pack(">Q", declared) + b"\xaa\xbb\xcc\xdd"

s.sendall(header) # header only, no body

4. Observe the server. Source shows the reader reaches `collector.start(2147483632, buffer)` and runs `ByteBuffer.allocate(2147483632)`. On a 256 MiB heap this allocation cannot be satisfied and the reader coroutine raises `OutOfMemoryError`. On a larger heap, open several connections that each send such a header to raise total committed memory.

5. Compare against a bounded server. Change the registration to the Route form with an explicit limit.

install(WebSockets) { maxFrameSize = 4L * 1024 * 1024 }

routing { mcpWebSocket { buildMcpServer() } }

The same frame now hits the guard at `WebSocketReader.kt:101` and the connection is closed with `FrameTooBigException` instead of allocating.

Scope was static source review only. No build, no dependency install, no server start, no frames sent, no memory-exhaustion run, no PoC executed. The handoff for this candidate forbids runtime testing, so the finding is carried on source evidence.

What source establishes, with the exact pinned files quoted above and snapshotted under `reports/mcp-kotlin-sdk-websocket-frame-allocation-dos/vulnerable-source/`.

- `Application.mcpWebSocket` installs the plugin with no `maxFrameSize` (`WebSocketMcpKtorServerExtensions.kt:45-63`).

- The Ktor default is `Long.MAX_VALUE` (`WebSockets.kt:104`) in both 3.5.1 and 3.5.2.

- A single frame is allowed up to `Int.MAX_VALUE` and the collector allocates that declared length as one buffer from the header (`WebSocketReader.kt:99-105`, `SimpleFrameCollector.kt:18-28`).

- The MCP transport applies no size check and the server transport applies no authentication (`WebSocketMcpTransport.kt:60-84`, `WebSocketMcpServerTransport.kt:16-23`).

- The same path is present and identical in release 0.15.0 and current main.

What is not established, and is stated as not established. The exact heap at which an `OutOfMemoryError` is thrown, whether any single deployment crashes outright, and the behavior of every Ktor server engine. `ByteBuffer.allocate(Int.MAX_VALUE)` on a HotSpot JVM can itself raise `OutOfMemoryError: Requested array size exceeds VM limit`, which is an availability event in the reader coroutine, but the process-level result depends on the deployment and was not measured.

**Availability only.** A caller who can open an endpoint registered through `Application.mcpWebSocket` can force the server to reserve a single on-heap buffer of an attacker chosen size up to 2 GiB per frame, from the frame header, with no SDK authentication and no SDK size check. One or a few connections can drive the server toward memory pressure or an `OutOfMemoryError` in the reader path. The attacker does not need to transmit the declared bytes, so the cost to the attacker is small.

- The host registers the endpoint with `Application.mcpWebSocket`, not the `Route.mcpWebSocket` form with a custom `maxFrameSize`.

- The listener is reachable by the attacker.

- The host adds no authentication layer that rejects the caller before the frame is read.

- No reverse proxy or engine applies a smaller frame or message bound ahead of Ktor.

- Demo, quickstart, and internal tools that follow the SDK's one-call helper and expose the port without auth are the most exposed. A single header can disrupt the process for all clients served by it.

- Deployments behind an authenticating proxy or a proxy that caps frame or message size are protected to the degree of that control, and the attacker must first pass it.

- Deployments that mount the route with an explicit `maxFrameSize` are not affected.

**Not in scope.** No confidentiality or integrity impact. No tool execution, no data disclosure, no remote code execution. The crash and its threshold are inferred from the allocation, not demonstrated.

**Where exactly:** `kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt:45-63`, the two `Application.mcpWebSocket` helpers.

-public fun Application.mcpWebSocket(block: () -> Server) {

+public fun Application.mcpWebSocket(block: () -> Server) {

+ maxFrameSize = DEFAULT_MAX_WS_FRAME_SIZE

with a shared constant to the existing HTTP bound.

+/** Default maximum size (in bytes) for an incoming WebSocket frame: 4 MiB. */

+internal const val DEFAULT_MAX_WS_FRAME_SIZE: Long = 4L * 1024 * 1024

Apply the same change to the `Application.mcpWebSocket(path, ...)` overload.

Short term. Set an explicit `maxFrameSize` in both `Application.mcpWebSocket` helpers, matching the 4 MiB the SDK already uses for HTTP and SSE bodies, or another value that comfortably exceeds a real MCP message.

Medium term. Document on the `Route.mcpWebSocket` helpers that the host must install Ktor WebSockets with a bounded `maxFrameSize`, since the SDK cannot set it for the Route form.

Defense in depth. Add a size check in `WebSocketMcpTransport` before `readText()` so the transport refuses an oversized frame even when a host misconfigures the plugin, and cap the JSON parse input the same way the HTTP path is capped.

Recommended: install the WebSockets plugin in both `Application.mcpWebSocket` helpers with an explicit `maxFrameSize` of 4 MiB, add a regression test that a frame header declaring a length above the bound is rejected with `FrameTooBigException` rather than allocated, and document the requirement for the `Route` form.

- Repository:

- Security policy (GitHub Security Advisories):

- Related advisory GHSA-f47c-p7wj-q7gx (Streamable HTTP body limit, fixed 0.14.0):

- Related advisory GHSA-4x39-xh35-w96w (inline SSE parsing, fixed 0.14.0):

- Related advisory GHSA-74gp-qhv5-v493 (stdio line buffering, fixed 0.13.0):

- No public advisory, issue, or pull request for the WebSocket frame path was found at the time of writing.

- Pinned SDK source, server helper:

- Pinned SDK source, transport:

- Pinned SDK source, bounded HTTP reader:

- Ktor 3.5.1 default maxFrameSize:

- Ktor 3.5.1 reader ceiling:

- Ktor 3.5.1 collector allocation:

Reported by HaiND (Nguyen Dinh Hai), Post and Telecommunication Institute of Technology, [github.com/Haind03](

Extracted Entities