CVE-2026-88629
漏洞介绍
/api/support/mcp/client/ 下的 getTools 与 runTool 两个端点均接收请求体中的 url 并由服务器主动向其发起 MCP/SSE 连接。两接口均无任何鉴权、url 完全由请求者控制、连接结果(拒绝/超时/HTTP 状态码/响应内容类型)以错误消息形式回显 —— 构成未认证 SSRF 与内网测绘 oracle
gettools接口的作用是mcp我收+拉取工具列表
runtool接口的作用是mcp握手+调用指定工具并返回执行结果
下面主要讲的是代码审计相关的东西
代码审计
代码审计已FastGPT-4.13.0版本为例
下载链接:https://github.com/labring/FastGPT/archive/refs/tags/v4.13.0.zip
理解项目架构
FastGPT 是一个 Next.js + TypeScript + MongoDB (Mongoose) 的 monorepo 项目
发现可疑目录
projects/app/src/pages/api/support/mcp/client

审计思考 ②:
support/目录在 FastGPT 里放的是”支撑功能”(用户、团队、外链、文件等),这里的mcp/client从命名看是”FastGPT 作为 MCP 客户端去连接别人的 MCP 服务器” —— 与core/app/mcpTools(把别人注册的 MCP 工具跑起来)职责相近但路径完全不同。每个文件一个端点,先看getTools.ts。
看到gettools.ts的代码内容
1 | |
看看这个代码大概的意思
NextAPI(handler)
NextAPI 是错误处理包装器,是否附带鉴权要去 middleware/entry.ts 确认
body = { url, headerSecret }
url 是请求体里的普通字段 —— 类型标注 string 只是编译期约束,运行时传什么就是什么
const { url } = req.body
用户输入原样解构,无 Zod/无白名单/无内网地址校验
new MCPClient({ url, ... })
用户可控的 url 即将交给服务器去连接 —— SSRF 的经典形态
headerSecret → headers
headerSecret 经过 getSecretValue 处理后,会作为 HTTP 请求头发送给目标 url
getTools() 结果直接返回
连接结果(成功/失败细节)回显给请求者 ,所以这里会返回错误的代码后续可以用来区分正确与错误的消息
到这里已经具备 SSRF 的全部要素:用户可控目标 + 服务器主动发起连接 + 结果回显。缺的只是一个确认:这个端点到底有没有鉴权?没有
req传给任何auth*函数,但没有显式拒绝也可能是全局中间件拦的 —— 必须排掉这个可能。
鉴权检查
FastGPT 的 API 包装器在 projects/app/src/service/middleware/entry.ts:

FastGPT 的 API 包装器在 projects/app/src/service/middleware/entry.ts:
1 | |
beforeCallback 是每个请求进来前依次执行的函数数组 —— FastGPT 所有需要登录的端点,都是把 authCert(Cookie/API Key 校验)这类函数放进各自文件的 beforeCallback 或在 handler 里显式调用。对比一个正常需要鉴权的端点(如 core/chat/getHistories.ts 内部):
1 | |
而 getTools.ts 的 handler 全文(第二步)没有出现任何 auth 字样,beforeCallback 又是空数组。两条路都排除后可以下结论:
鉴权结论:
/api/support/mcp/client/getTools无任何身份验证 —— 未登录、无 Cookie、无 API Key 均可直接调用。
顺手确认 runTool.ts与全局是否有其他拦截(Nginx 层的 WAF 因部署而异,代码层无拦截)。
读 runTool.ts
现在回到projects/app/src/pages/api/support/mcp/client
1 | |
与 getTools 完全同构:无鉴权 + url 直通。差异是交互更深一层 —— toolName 与 params 会作为 MCP 协议的 tools/call 参数发到目标服务器,执行结果原样返回给请求者
两个端点、同一根因(无鉴权 + URL 直通)
审计总结
根据上面两个接口没有任何鉴权以及用户可以自由控制请求路径还有服务端会返回请求结果的表现来看,两个接口存在ssrf漏洞
攻击者构造如下请求包就可以探测内网
请求包(getTools)
1 | |
响应(服务器真实访问了内网沙箱并读取响应头):
1 | |
请求包(runTools)
1 | |
响应(与 getTools 完全一致的回显 oracle):
1 | |
runTool 相对 getTools 的增量:toolName 与 params 完全可控 —— 当 url 指向内网真实 MCP 服务时可发起协议级工具调用;指向攻击者服务器时可在 body 中携带任意 arguments(同系列目标已用 webhook.site 实证:两接口均产生 GET+POST 成对回连,UA=node,来源 IP 为目标服务器本身)。
判定逻辑:可达目标返回”内容类型/证书校验”错误、关闭端口返回”ECONNREFUSED + IP:端口”,两类响应可区分且均由服务器侧产生 → 两接口 SSRF 均成立
实战案例


实际测试下来发现
用 mcp/client getTools 对目标内网地址段做系统化探测,每种网络状态都有可区分的回显:
| 探测地址 | 回显 | 状态判定 |
|---|---|---|
http://127.0.0.1:3000 |
connect ECONNREFUSED 127.0.0.1:3000 |
闭合(精确回显 IP:端口) |
http://127.0.0.1:3001/3002/27017/6379/80/443/47654 |
全部 ECONNREFUSED 127.0.0.1:<端口> |
逐端口闭合确认 |
http://172.18.0.1:3000 |
Invalid content type |
开放(HTTP 服务,非 MCP) |
http://172.18.0.1:3000/definitely-not-exist-xyz |
Non-200 status code (404) |
开放且服务指纹可读(404 应答) |
http://172.18.0.1:3001 |
connect ECONNREFUSED 172.18.0.1:3001 |
同主机闭合端口 |
http://172.18.0.1:27017 |
connect ECONNREFUSED 172.18.0.1:27017 |
同主机闭合端口 |
http://172.17.0.1:80/3000/3001/3005/3306 |
无回显(超时) | 被过滤(与闭合状态明确区分) |
http://10.0.0.1:80 |
无回显(超时) | 被过滤/不可达 |
http://169.254.169.254/latest/meta-data/ |
无回显(超时) | 云元数据被防火墙过滤 |
结论:三种网络状态(开放/闭合/被过滤)通过不同回显完全可区分,且在同一内网主机(172.18.0.1)上实现了逐端口判别 —— 该 SSRF 是一个完整的内网端口扫描与服务指纹识别 oracle,实测即发现了内网开放服务 172.18.0.1:3000