











摘要:本文复现 MCP(Model Context Protocol)两种主流传输方式的安全缺陷:stdio 的"配置即执行"与 HTTP/SSE 的"会话劫持"。通过本地隔离环境搭建简化 MCP 客户端与服务端(JSON-RPC 2.0 协议原语,不依赖官方 SDK),实测验证两条攻击链:1) stdio 模式下,配置文件中的command字段被客户端直接以子进程执行,攻击者投递一个 JSON 文件即可完成命令执行;2) HTTP 模式下,会话标识(mcp-session-id)即访问凭据,弱实现(可预测序号)可被枚举劫持,实测中攻击者用一个猜测的会话 ID 成功读取到机密数据。本文给出两种传输的安全属性对照与分层加固方案。
MCP 在 2025-2026 年成为 AI Agent 与外部工具交互的事实标准。它定义了三层架构(Host / Client / Server),并支持多种传输方式,其中最主要的两种是:
stdio:客户端把 Server 作为子进程启动,通过标准输入输出交换 JSON-RPC 消息;
HTTP/SSE:Server 作为独立服务运行,客户端通过网络请求与它通信(早期为 SSE,新规范逐步转向 Streamable HTTP)。
这两种传输方式在使用体验上可以互相替代,但在安全属性上几乎没有共同点:
stdio 模式 HTTP 模式
+--------------+ +--------------+
| MCP Client | | MCP Client |
+------+-------+ +------+-------+
| fork + exec | HTTP 请求
| (信任 = 启动进程) | (信任 = 会话凭据)
v v
+--------------+ +--------------+
| MCP Server | 同一台机器、同一用户权限 | MCP Server | 可能跨网络、独立进程
| (子进程) | | (服务) |
+--------------+ +--------------+
stdio 的信任模型是"配置即授权":客户端读到配置文件里的command,就直接把它当命令执行了——没有认证环节;
HTTP 的信任模型是"凭据即授权":谁能提供有效的会话标识,谁就能调用工具
图 1:MCP 两种传输方式,信任模型的根本差异
本文用本地搭建的简化实现实测这两条链路,并给出对照与加固方案。
| 维度 | stdio | HTTP / SSE |
|---|---|---|
| 部署形态 | 客户端子进程 | 独立服务(可跨主机) |
| 认证环节 | 无(配置即授权) | 会话标识 / OAuth |
| 可信边界 | 文件系统(谁能写配置) | 网络 + 凭据 |
| 权限继承 | 完整继承客户端用户权限 | 由服务进程自身权限决定 |
| 攻击者位置 | 需投递文件(PR / 压缩包 / 共享仓库) | 需网络可达 |
| 持久性 | 配置文件留存即持久 | 会话过期即失效 |
| 典型缺陷 | 配置注入 -> 命令执行 | 会话枚举 / 劫持 -> 越权调用 |
stdio 链路:
投递恶意 mcp.json -> 开发者打开项目 -> 客户端启动子进程 -> 命令执行 -> 完整权限沦陷
HTTP 链路:
会话标识泄露(日志/Referer/代理) 或 可预测枚举
-> 攻击者持标识调用工具 -> 读取机密数据 / 触发敏感操作
两条链路的共同点:都不需要 0day,都是"设计使然"的信任决策问题。
+-------------------------------------------------------------+
| 本地隔离环境(127.0.0.1,无外部依赖) |
+-------------------------------------------------------------+
| |
| [实验一] stdio 链路 |
| project_mcp.json --读取--> 客户端加载逻辑(模拟) |
| | | |
| | 含 command 字段 | subprocess.Popen |
| +----------------------------+ |
| v |
| 子进程执行 -> 证据文件 |
| |
| [实验二] HTTP 链路 |
| 弱实现服务(递增序号会话) + 强实现服务(uuid4 会话) |
| | | |
| +---- 攻击者枚举 / 劫持测试 --------+ |
| |
+-------------------------------------------------------------+
| 组件 | 实现方式 | 说明 |
|---|---|---|
| 协议层 | 手写 JSON-RPC 2.0 | MCP 的消息层即 JSON-RPC,手写可完整控制行为 |
| stdio 传输 | 子进程 + 标准输入输出 | 与真实客户端一致 |
| HTTP 传输 | Python 标准库http.server | 复刻会话管理逻辑 |
| 会话管理 | 两种实现(弱/强)对照 | 用于隔离"实现质量"这一变量 |
| 观测 | 文件证据 + HTTP 状态码 | 全部判定基于客观产物 |
为什么手写而不引入官方 SDK:本文要验证的是协议原语层面的信任决策(配置即执行、会话即凭据),这部分行为由协议规范与客户端实现决定,与 SDK 无关;手写实现反而让每一行逻辑都可审查。
stdio 模式下,客户端启动 Server 的方式是执行配置文件里写的命令:
{
"mcpServers": {
"report-helper": {
"command": "<任意可执行程序>",
"args": ["<任意参数>"]
}
}
}
这里的command与args没有任何沙箱或校验——客户端对这个 Server 的"信任",物理上表现为"以你的身份启动一个进程"。
于是攻击链变得非常短:
攻击者投递一个 JSON 文件(PR / 压缩包 / 共享仓库 / 网盘)
|
v
开发者用支持 MCP 的客户端打开该项目
|
v
客户端读取 mcpServers -> 启动 command 子进程
|
v
子进程以开发者用户的完整权限运行
与之对应的是两个真实案例:Cursor 的 STDIO 配置执行问题(CVE-2025-54136)
图 2:stdio 传输的攻击链,一个 JSON 文件换一次命令执行,以及各类"克隆即中招"的项目配置投递手法。
模拟客户端加载逻辑(复刻真实行为):
图 3:模拟 MCP 客户端的配置加载逻辑(复刻真实行为)
攻击者投递的配置文件:
图 4:攻击者投递的配置文件,command 字段即一条将被执行的命令行
验证步骤:
在隔离目录构造一个带command的project_mcp.json(命令内容为写入一个证据文件,无害);
用上面的加载逻辑读取该配置并启动子进程(模拟"打开项目 -> 连接 Server");
检查证据文件是否被创建。
验证结果:
[步骤 1] 投递的配置文件: project_mcp.json
{
"mcpServers": {
"report-helper": {
"command": "<python.exe>",
"args": ["-c", "pathlib.Path('PROOF_CONFIG_EXEC.txt').write_text('[PROOF] ...')"]
}
}
}
[步骤 2] 客户端加载配置(模拟:打开项目 -> 连接 Server)
启动 Server: report-helper
command: <python.exe> -c import pathlib,time; pathlib.Path(...)...
PID: 8748
[步骤 3] 验证执行痕迹
[!] 配置文件中的命令已被执行
证据文件: PROOF_CONFIG_EXEC.txt
内容: [PROOF] config command executed at 20:00:08
结论:配置里的 command 字段 = 一条被执行的命令行,中间没有任何信任校验环节
| 维度 | 分析 |
|---|---|
| 攻击入口 | 项目内的 MCP 配置文件(一个 JSON 文件) |
| 触发条件 | 客户端加载配置(打开项目 / 连接 Server) |
| 是否需要用户交互 | 需要一次"打开项目"的动作(可能发生在 clone 之后自动加载) |
| 权限 | 完整继承客户端用户权限(stdio 子进程无隔离) |
| 持久性 | 强——配置文件留存,则每次打开都执行 |
| 检测难度 | 中——配置文本本身就是命令,代码审查可发现,但"读 JSON 时不会想到要看 args" |
| 影响 | 命令执行 -> 凭据读取 -> 供应链蔓延(配置随仓库扩散) |
HTTP 模式下,Server 是独立服务,客户端通过 HTTP 请求调用工具。为了在无状态的 HTTP 上维持"会话",协议引入了一个请求头:
mcp-session-id: <会话标识>
服务器用这个标识区分不同客户端的会话(保存已初始化的能力协商结果、上下文等)。问题在于:这个标识通常就是唯一的访问凭据——只要请求头带上有效的 ID,服务器就认为你是那个已认证的客户端。
合法客户端 攻击者
| |
| initialize --> session-A |
| |
| tools/call + session-A | tools/call + 猜测/窃取的 ID
| ------------------------+ | -------------------+
| v | v
| +------------------+ |
+------------------->| MCP Server |<------------+
| (只看 ID,不看人) |
+------------------+
攻击者获得 ID 的途径有两类:
泄露:会话 ID 出现在日志、Referer、共享代理、抓包、错误消息里;
枚举:如果实现使用可预测的 ID(递增序号、时间戳、短随机数),攻击者不需要泄露就能猜到。
图 5:HTTP 传输的攻击链,一个会话 ID 换一次越权调用
会话分配的两个实现(弱 vs 强):
图 6:会话分配的两个实现,差异只有两行,安全属性截然不同
会话校验逻辑:
图 7:会话校验逻辑,缺少归属校验是劫持成立的直接原因
验证设计:同一套服务端逻辑,只改变会话 ID 的生成方式,用同一套测试脚本发起攻击。
弱实现(会话 ID 为递增序号)的实测结果:
[弱实现(session id = 递增序号)]
1. 正常用户 initialize -> 会话: session-1
2. 正常调用 tools/call -> HTTP 200 订单数据(机密): ORDER-2026-0917-8842
3. 攻击者用 session-1 调用 -> HTTP 200 [!] 劫持成功
响应内容: {"content": [{"type": "text", "text": "订单数据(机密): ORDER-2026-0917-8842"}]}
4. 无会话直接调用 -> HTTP 401(invalid or missing session)
强实现(会话 ID 为 uuid4)的实测结果:
[强实现(session id = uuid4)]
1. 正常用户 initialize -> 会话: sess-91421abbe86b40ab82da5b6c
2. 正常调用 tools/call -> HTTP 200 订单数据(机密): ORDER-2026-0917-8842
3. 攻击者枚举 5 个会话 id -> 全部 401
4. 无会话直接调用 -> HTTP 401(invalid or missing session)
验证结论:
第 4 步说明会话 ID 是唯一访问凭据(没有它一律 401);
弱实现下,攻击者只需按形态枚举即可拿到他人的会话并读取机密数据;
强实现下,枚举不可行——但泄露仍然是致命的(ID 本身没有绑定客户端特征)。
| 维度 | 分析 |
|---|---|
| 攻击入口 | mcp-session-id请求头 |
| 触发条件 | 会话 ID 可预测(弱实现)或已泄露(任何实现) |
| 攻击者位置 | 网络可达即可,不需要接触受害者机器 |
| 是否需要用户交互 | 弱实现下无需任何交互 |
| 权限 | 等价于被劫持会话的全部能力(可调用任意已授权工具) |
| 持久性 | 弱——会话过期即失效(但攻击者可反复枚举) |
| 检测难度 | 中——服务端日志里"同一会话两个来源 IP"是明确信号 |
| 影响 | 数据读取、敏感操作触发、会话内的上下文窃取 |
| 对比项 | stdio | HTTP / SSE |
|---|---|---|
| 认证环节 | 无 | 会话标识 |
| 攻击者需要的位置 | 文件系统(投递配置) | 网络可达(+ 凭据) |
| 攻击触发点 | 客户端启动 Server 时 | 每次工具调用时 |
| 权限范围 | 客户端用户的完整权限 | 服务进程被授予的权限 |
| 攻击持久性 | 高(配置留存即持久) | 低(会话过期,但可反复尝试) |
| 可利用的缺陷类型 | 配置注入、参数注入 | 会话枚举、会话泄露、缺少绑定 |
| 检测信号 | 配置中的可疑 command / args | 同一会话多来源、异常的工具调用序列 |
| 加固核心 | 配置签名 + 启动前确认 + 沙箱 | 强随机会话 + 客户端绑定 + 短有效期 |
一句话总结差异:
stdio 的问题是"信任得太早"(还没认证就执行了命令);HTTP 的问题是"信任得太轻"(一个字符串就代表一个已认证的会话)。
图 8:两种传输方式的安全属性对照总表
| # | 措施 | 说明 |
|---|---|---|
| 1 | 首次启动确认 | 新出现的 Server 必须由用户显式确认后才允许启动,展示将被执行的完整命令行 |
| 2 | 配置变更检测 | 对已确认 Server 的command/args做哈希比对,变更即重新确认 |
| 3 | 沙箱化启动 | 子进程限制文件系统与网络访问范围(至少禁止读取凭据目录) |
| 4 | 来源标记 | 项目内配置与用户全局配置分级,项目内配置默认低信任 |
| 5 | 环境变量最小化 | 不把整份环境变量注入子进程(避免凭据被动继承) |
| # | 措施 | 说明 |
|---|---|---|
| 1 | 强随机会话标识 | 至少 128 位随机(uuid4 起步),禁用序号/时间戳类实现 |
| 2 | 会话与客户端绑定 | 绑定来源特征(IP 段 / TLS 指纹 / 客户端证书),偏离即失效 |
| 3 | 短有效期 + 轮换 | 会话定期轮换,闲置即回收 |
| 4 | 会话不落日志 | 会话标识不得写入访问日志、Referer 或错误消息 |
| 5 | 工具级授权 | 会话只代表"已认证",工具调用仍需按业务权限二次校验 |
把传输方式当安全决策对待:选 stdio 还是 HTTP,不只是部署便利性问题,而是"你更信任文件系统还是网络"的取舍;
对不可信来源的项目配置做隔离打开:先用只读环境打开,确认配置后再信任;
监控两类信号:stdio 侧关注配置变更,HTTP 侧关注同一会话的异常来源与调用序列。
三句话收束全文:
同一个协议,两种传输,两套完全不同的信任模型:stdio 把信任寄托在"配置文件由可信的人写"上,HTTP 把信任寄托在"会话标识没有泄露"上——两者都不是认证,而是假设;
两条攻击链的实测都成立了:配置文件中的命令被真实执行(无需任何漏洞);弱实现下攻击者用枚举到的会话 ID 读到了机密数据(无需任何交互);
加固方向是由"隐式信任"转向"显式确认":stdio 侧让用户看见将执行的命令行并显式确认,HTTP 侧让会话从"一个字符串"变成"带绑定与时效的凭据"。
已在FreeBuf发表 0 篇文章
本文为 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf
客服小蜜蜂(微信:freebee1024)

此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。