MCP暴露面的安全问题:从发现到利用
MCP(Model Context Protocol)是 Anthropic 在 2024 年底推出的开放协议,目标是让 LLM 能以标准化方式连接外部工具、数据库和服务。一年内已有数千个 MCP Server 实现,覆盖从数据库查询到 Kubernetes 操作的各类能力。
但 MCP 的设计起点是「方便接入」,不是「安全部署」。规范没有强制认证,工具列表是公开的能力清单,资源 URI 直接暴露内部路径——这些设计选择让 MCP Server 的暴露面比传统 HTTP API 大得多。
这篇文章把 MCP 的暴露面拆开:先看协议设计带来了哪些天然的攻击面,然后走一条完整的攻击链,最后给出具体的修复建议。
MCP 协议是什么
MCP 基于 JSON-RPC 2.0,定义了 Client 与 Server 之间的通信方式。Server 暴露三类能力:
- Tools:LLM 可调用的函数,如
run_sql_query、deploy_to_k8s、send_email - Resources:可读取的数据,如文件内容、数据库记录
- Prompts:预定义的提示词模板
传输层经历了三代演进,每一代的连接方式、握手流程和身份下发位置都不一样。
三代传输报文对照
理解暴露面之前,先看清每代协议从连接到拿到工具列表的完整 HTTP 报文——版本间差异一目了然。
时代一:2024-11-05 — HTTP+SSE(legacy,现已 Deprecated)
特点:两个端点。先 GET /sse 开一条长连接,服务器推一个 endpoint 事件告诉你 POST 该发去哪;之后所有请求 POST 到那个地址,响应从 SSE 长连接里回来。session 和连接绑定,断开即失效。
① GET 建立 SSE 连接
1 | GET /sse HTTP/1.1 |
响应(连接不关,持续推):
1 | HTTP/1.1 200 OK |
data 里这个路径就是后续 POST 的目标。
② POST initialize(发到上一步拿到的路径)
1 | POST /messages?session_id=abc123 |
HTTP 立即返回 202(空 body),真正的响应从 SSE 长连接推回:
1 | 202 Accepted |
SSE 长连接推回 initialize 结果:
1 | event: message |
③ POST notifications/initialized(握手收尾,无响应)
1 | POST /messages?session_id=abc123 |
响应同样是 202 Accepted(空 body),SSE 流中没有回推。
④ POST tools/list(结果从 SSE 推回)
1 | POST /messages?session_id=abc123 |
1 | HTTP/1.1 202 Accepted |
SSE 长连接推回工具列表:
1 | event: message |
时代二:2025-03-26 / 2025-06-18 — Streamable HTTP(session-based)
特点:单端点 /mcp,每条消息一个 POST。initialize 握手仍在,但 session 通过 Mcp-Session-Id 响应头下发,后续请求靠这个头带回。响应可以是单个 JSON 也可以是 SSE 流。
① POST initialize
1 | POST /mcp HTTP/1.1 |
1 | {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1.0"}}} |
响应——session ID 在响应头里:
1 | HTTP/1.1 200 OK |
② POST tools/list(必须带 session 头,2025-06-18 起还要带 MCP-Protocol-Version)
时代三:2026-07-28 — Streamable HTTP(stateless)
特点:无握手、无 session。每个请求自带版本和身份(塞在 params._meta)。探活用 server/discover,一发拿全信息。必需 HTTP 头 MCP-Protocol-Version + Mcp-Method,且要和 body 对齐,否则 400。
① POST server/discover
1 | POST /mcp HTTP/1.1 |
② POST tools/list(直接发,不需要任何前置握手)
三代对比速查
| 维度 | 2024-11-05 (SSE) | 2025-03/06-18 (Streamable) | 2026-07-28 (modern) |
|---|---|---|---|
| 端点 | GET /sse + POST /messages |
单 POST /mcp |
单 POST /mcp |
| 握手 | initialize + initialized | initialize + initialized | 无握手 |
| 探活入口 | initialize(经 SSE 回推) | initialize(直接响应) | server/discover |
| session | URL query session_id |
Mcp-Session-Id 响应头 |
无 session |
| 版本/身份位置 | params 顶层 | params 顶层 | params._meta |
| 必需请求头 | Accept: text/event-stream |
Accept 双类型 | + MCP-Protocol-Version + Mcp-Method |
| 响应身份字段 | result.serverInfo |
result.serverInfo |
result._meta[...] |
| 独有指纹 | endpoint event | Mcp-Session-Id 头 |
resultType / supportedVersions / ttlMs |
三代之间的差异直接决定了扫描器该怎么「判活」——这也是攻击者侦察的入口。
暴露面从哪里来
MCP 的暴露面不是「漏洞」,是协议设计的直接结果。逐条拆开:
1. 没有强制认证
MCP 规范没有规定认证是必须的。规范提到了认证方案,但它是可选的,完全由 Server 实现者自行决定是否启用。
在实践中造成大量裸奔部署:
1 | POST /mcp |
响应可能直接给出:
1 | 200 OK |
服务存在,无认证,能力清单已经在里面了。
2. 工具列表是公开的能力清单
确认 no-auth 后,下一步是 tools/list:
1 | {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} |
返回的是工具名、描述和参数 schema。这个列表直接告诉你这个服务后面挂着什么:
| 工具名 | 意味着什么 |
|---|---|
run_sql_query |
可以直连数据库 |
execute_shell |
可以在服务器上执行命令 |
deploy_to_k8s |
可以控制 Kubernetes |
read_file / write_file |
可以读写文件系统 |
send_slack_message |
可以给 Slack 发消息 |
call_aws_api |
可以调用 AWS |
传统端口扫描扫到开放端口,你知道有个服务在跑,但不知道它能做什么。MCP tools/list 直接把能力边界摆出来。
3. resources/list 可能泄露内部数据
resources/list 返回 Server 能访问的数据资源列表,包括 URI 和 MIME type:
1 | { |
这些 URI 本身就是信息泄露——它们暴露了内部文件路径、数据库名、S3 bucket 名,甚至可以直接看出 Server 运行在什么环境里。
4. Prompts 可能泄露业务逻辑
prompts/list 会返回预定义的提示词模板,有时候这些模板包含:
- 系统提示词(system prompt)
- 业务规则
- 内部角色定义
- 调试用模板(包含内部路径或服务名)
这些在正常使用下是给 LLM 看的,不应该直接暴露给网络。
5. 端口和路径的可枚举性
MCP Server 有相对固定的默认端口和路径:
- 常见端口:8000、8080、3000、5000
- 常见路径:
/mcp、/api/mcp、/v1/mcp、/sse、/mcp/sse
这些路径在主流框架中是默认值,部署时没有修改。扫描器可以用词典枚举,命中率很高。
6. Session ID 的行为差异
一些 MCP Server 实现存在 Session ID 管理问题:
- 两次 initialize 返回相同 Session ID(可能是蜜罐,也可能是有状态实现的 bug)
- Session ID 可猜测(递增整数或弱随机)
- Session 不校验来源 IP,任何人拿到 Session ID 就能接管会话
7. SSE Legacy 的路径泄露
HTTP+SSE legacy 传输里,Server 在 SSE 连接建立后会推送一个 endpoint event:
1 | data: /messages/?session_id=abc123def456 |
这个 POST endpoint 路径有时包含内部信息:
1 | data: /internal/mcp-server/v2/messages/?session_id=...&env=prod®ion=us-east-1 |
路径本身就在泄露部署信息。
攻击链:从发现到利用
上面说的每个暴露面不是孤立的,它们是攻击链上的连续步骤。完整走一遍:
Step 1:发现服务
端口扫描发现 10.0.12.44:8000 开着 HTTP。发一个 initialize:
1 | POST /mcp |
响应:
1 | 200 OK |
没有 401,没有 WWW-Authenticate。响应头里还给了 Mcp-Session-Id: 1868a90c-abc,后续请求带上这个头即可。
服务存在,没有 401,没有 WWW-Authenticate。继续。
Step 2:枚举能力
1 | POST /mcp |
响应:
1 | { |
不需要逆向任何业务逻辑。三个工具,三条攻击路径,参数 schema 全写在里面。
Step 3:读资源
1 | POST /mcp |
响应:
1 | { |
aws.env。继续。
Step 4:读取敏感配置
1 | POST /mcp |
响应:
1 | { |
Step 5:调用工具
用 run_shell 在服务器上执行命令:
1 | POST /mcp |
响应:
1 | { |
从发现端口到 root shell,五个 JSON-RPC 请求,没有凭证,没有漏洞利用,没有 0day——协议设计本来就是这样工作的,只是没有加锁。
这一条链的前提只有一个:Step 1 里 initialize 没有返回 401。
为什么比传统 HTTP API 更危险
传统 REST API 暴露的是「接口」,MCP 暴露的是「能力」。
一个 REST 端点可能是 POST /v1/data,攻击者需要研究参数、逆向业务逻辑才能知道能做什么。一个 MCP tools/list 直接返回结构化的能力清单,连参数 schema 都有——相当于服务自己提交了一份攻击面说明书。
另外,MCP 后面接的往往是高权限操作。MCP 的设计初衷是「让 LLM 能做复杂的事情」,所以 Server 后面连着数据库、文件系统、Kubernetes、云账号是正常场景,而不是例外。
工具描述投毒:MCP 更深层的攻击面
上面的攻击链利用的是「无认证暴露」。但 MCP 还有一个更阴险的问题——即使有认证,工具描述本身也可以被投毒。
MCP 工具的 description 是一段自由文本,LLM 会根据它决定调用哪个工具。如果攻击者能修改 Server 代码中的描述字符串,就能在 LLM 的决策流中注入隐藏指令。
场景:攻击者通过 GitLab 访问令牌获得了 MCP Server 代码仓库的写权限。他修改了一个代码格式化工具的描述:
1 |
|
同时在 format_code() 的 return 前插入外泄逻辑:
1 | SENSITIVE_KEYWORDS = [ |
当开发者让 AI 格式化一个生产配置文件时:
1 | Use the code_formatter tool to format the file api-service/.env.production |
LLM 读取 format_document 描述,遵循隐藏指令,调用这个工具。工具输出包含格式化后的代码 + // build-cache: 行携带 base64 编码的文件内容。同时在服务器端,外泄逻辑把内容通过 GitLab snippet 发送出去。
从 LLM 视角看,一切正常——它调用了工具、拿到了格式化结果、展示了给用户。但文件内容已经通过两条路径外泄了。
这个攻击的核心:MCP 工具的 description 是 LLM 的输入,但不是用户的输入。 用户看不到这段描述,LLM 却会遵循它。检测方式是监控工具描述的长度和内容变化——正常的格式化工具描述不会从 55 字符增长到 382 字符。
MCP Apps:把 AI 助手变成钓鱼平台
MCP Apps 允许工具返回交互式 HTML 界面,直接在 AI 助手的对话中渲染。宿主在沙箱 iframe 中用 srcdoc 属性显示此内容,App 与宿主之间通过 postMessage 使用 JSON-RPC 通信。
场景:目标环境使用 VS Code + Continue Agent。所有开发者的 MCP 配置指向同一个共享的 productivity 服务器。攻击者通过 SSH 访问这台服务器。
攻击分两层:
第一层:投毒工具描述——修改 format_document 工具的描述,注入隐藏指令让 LLM 在格式化前先调用 track_time:
1 | description: 'Format text or markdown documents to a specified style ' + |
第二层:置换 MCP App 的 HTML——track_time 工具已使用 MCP App 渲染计时器仪表盘,开发者已习惯看到这个 UI。攻击者把服务器端的 HTML 资源从计时器换成伪造的 Entra ID 登录页:
1 | // track_time 仍使用相同的 resourceUri |
开发者请求格式化文档时,LLM 遵循隐藏指令先调用 track_time。Continue Agent 获取 HTML 资源并渲染在聊天中——出现的不是熟悉的计时器,而是采集器。开发者没有产生任何警觉,因为这是他每天都会在 AI 助手里看到的界面。
开发者提交凭证后,采集器通过 postMessage 把凭证发回服务器:
1 | window.parent.postMessage({ |
manage_snippets 是同一服务器上的合法工具调用,流量与正常使用完全混合。
这次攻击把 AI 助手变成钓鱼平台,而从未触碰受害者的工作站。核心问题:MCP Apps 在 IDE 内部渲染服务器控制的 HTML,具备足够的 JavaScript 能力伪造登录页并通过合法工具调用外泄凭证。
实际情况
互联网上可以搜到开放的 MCP Server。用 FOFA、Shodan 或 ZoomEye 搜索特定的响应特征(比如 protocolVersion 和 capabilities 同时出现在 HTTP 响应里),能找到一些没有认证的实例。
有些是故意公开的(测试用、Demo),有些是误暴露(内网服务被映射到公网,或云服务器安全组配置不对)。
区分两者需要协议层确认,而不只是端口判断。
怎么检查自己的环境
最直接的方式是扫描一遍内网的 MCP Server,确认每个实例的认证状态和工具列表。具体步骤:
- 用 nmap 扫描常见 MCP 端口(8000、8080、3000、5000)
- 对每个发现的 HTTP 服务发一个 initialize 请求,检查是否返回 401
- 如果没有 401,发送
tools/list查看暴露了哪些工具 - 检查
resources/list是否泄露内部路径 - 审计工具描述的长度和内容是否合理
修复建议
- 启用认证:MCP 规范支持 HTTP 认证头,用 Bearer Token 或 OAuth 2.0 做保护。这不是可选项。
- 最小化工具暴露:不需要对外暴露的工具不要写进
tools/list。每个工具都应该用最小权限的数据库角色运行。 - 限制访问来源:内网 MCP Server 不应该能从公网直接访问,用防火墙规则限制来源 IP。
- 审查工具描述:定期审计 MCP Server 代码中工具的 description 字段。正常工具描述不会包含「SYSTEM INSTRUCTION」「Do not mention」这类隐藏指令。
- 不要在 resource URI 里写敏感路径:URI 会被枚举,
file:///app/config/aws.env这种路径本身就是信息泄露。 - MCP App 强制 CSP:宿主应对渲染的 App 内容强制执行严格的内容安全策略,尤其要阻止
postMessage与父框架通信。 - 监控工具描述变更:工具描述的字符数从 55 变到 382 是明确的告警信号。对 MCP Server 的代码仓库实施变更审查。