MCP SSRF 连环漏洞拆解:从 Google 到 mcp-server-fetch,Agent 的信任链如何变成攻击链

2026年10月的第一周,MCP生态经历了一场无声的地震。不是某个模型又刷了新榜,不是某个Agent框架又多了几个star,而是一组编号------CVE-2026-104120、CVE-2026-14540、CVE-2026-97228、GHSA-c9xm-49cp-xcr9------像一排钉子,把"Agent安全"这个一直飘在PPT上的词钉进了现实。

这些编号的共同点只有一个:SSRF,服务器端请求伪造。一个存在了二十年的老漏洞类型,换了一副Agent的面孔重新登场。攻击者不再往表单里填恶意URL,而是往网页、文档、工单里埋一段文字。Agent读到它,把它当作正常指令转发给下一个Agent,后者信任前者,照做。请求就这样从内网服务器发出,打向了本不该被打到的地方------数据库、元数据端点、内部管理接口。漏洞本身没有新意,攻击面却是全新的。

独立安全研究员Syed Anas Mohiuddin在2026年前九个月里,用同一套思路在Google、Anthropic、Microsoft、Weaviate的MCP服务器中都找到了相同形状的洞。他给这类攻击起了个名字:协议枢转(Protocol Pivoting)。攻击者通过一个协议进入,利用协议之间的信任假设,在另一个协议上拿到本不该有的能力。10月6日,The Next Web报道,JPMorgan Chase、法国政府跨部门数字管理局(DINUM)、印尼丹格朗市政府也修复了同类问题,而美国总务管理局技术转型服务处的五个MCP服务器仍在分诊中。

这篇文章不打算罗列新闻标题。我们拆开看三个最具代表性的漏洞实例------Google mcp-toolbox的8.0分缺陷、mcp-server-fetch那个存在却从未被调用的守卫函数、以及rmcp客户端的OAuth元数据SSRF------然后给出一份可以直接抄进生产环境的防御清单。每一个案例都会落到具体的代码路径和修复提交上,不停留在"尽快升级"这种正确的废话上。

在展开之前,有必要先对齐一个背景认知:MCP(Model Context Protocol)从2024年11月Anthropic首次发布到现在,只经过了不到两年时间。但到2026年10月,OX Security的一份普查报告已经在公网上数出了15465个活跃的MCP服务器实例,其中2.3%的主机名已经不再解析,6个过期域名可以被任何人以每年4到12美元的价格重新注册。15.6%的主机名解析到了美国境外的基础设施上,包括位于中国和俄罗斯的主机。协议成熟的速度远远超过了围绕它的注册卫生和治理能力。

攻击面解剖:信任链上的三个断层

MCP的设计初衷是让AI应用和Agent之间有一个标准化的通信协议。2026年7月28日发布的无状态规范进一步简化了部署------不再需要会话握手,不再需要Mcp-Session-Id,每个请求自包含、可独立路由。这解决了扩展性问题,但也把一个老问题推到了更显眼的位置:当Agent A通过MCP把任务委托给Agent B,Agent B凭什么相信A?

答案在当前的MCP实现里是:默认信任。MCP服务器为每个Agent存储凭据,Agent被设计为信任内部网络中的每一个其他Agent。这种信任模型在单机时代或许够用,但在多Agent协作的架构里,它创造了一条攻击者可以走过的信任链。

断层出现在三个位置。

**第一,服务端:Agent替服务器发请求。**一个Agent收到包含恶意URL的任务,调用MCP服务器上的工具去抓取或请求该URL。服务器没有验证目标地址是否安全,直接发出请求。SSRF就这样从Agent的"手"变成了服务器的"口"。

**第二,客户端:OAuth元数据发现变成了SSRF通道。**MCP客户端在OAuth流程中会从服务器返回的WWW-Authenticate头里读取resource_metadata URL并发起请求。如果这个URL指向内网地址或云元数据端点,客户端就会从受害者网络内部发出请求。

**第三,运行时:Agent的执行角色权限过大。**即使Agent本身没有被直接攻击,只要它能访问实例元数据服务(IMDS),攻击者就能通过提示注入让Agent去请求169.254.169.254,拿到临时凭据,再用这些凭据从外部访问整个AWS账户。

Rapid7的漏洞情报总监Douglas McKee说得很直白:"AI Agent给了攻击者一组全新的连接可以走。有人种一段文字,一个Agent读到它,把它当作正常的委托任务传给另一个Agent,后者因为信任前者而执行。链条上的每一环都完全按设计运行,这正是它难以捕捉的原因。"

CVE-2026-14540:Google mcp-toolbox的8.0分HTTP客户端缺陷

Google的MCP Toolbox for Databases是一个让Agent直接查询数据库的工具集,版本0.3.0到1.4.0的通用HTTP源在初始化HTTP客户端时犯了一个教科书级的错误:没有设置CheckRedirect策略,也没有验证目标IP地址。

CheckRedirect策略控制服务器在遇到HTTP重定向时的行为。没有这个策略,一个精心构造的路径参数就能让工具箱跟随重定向跳到内部端点,代表攻击者发出请求。Mohiuddin的解释是:"Google的修复涉及应用IP范围允许列表和阻止列表。它在启动时而不是首次请求时拒绝不安全的基URL。这才是真正的SSRF守卫应该有的样子。这也是大多数MCP服务器还没做的工作。"

修复方案是googleapis/mcp-toolbox的PR #3448,随v1.5.0发布。核心变化是在HTTP客户端初始化时注入一个重定向检查钩子,每次重定向都会验证目标地址是否在允许范围内。如果不在,请求在启动阶段就被拒绝,而不是等到连接已经建立。

这个漏洞在CVSS 4.0下拿到8.0分(High),原因是它的利用条件极低------不需要认证,不需要用户交互,只需要让Agent读到一段包含恶意URL的文字。在Agent架构里,这段文字可以出现在任何地方:一个网页、一封邮件、一份PDF、一个工单的描述字段。

CVE-2026-104120:守卫函数存在,但从未被调用

mcp-server-fetch是MCP官方参考实现中的抓取服务器,几乎所有用MCP做过网页抓取的开发者都直接或间接用过它。CVE-2026-104120描述了fetch_url函数中的SSRF漏洞,CVSS 3.1评分7.3(High),CVSS 4.0评分6.9(Medium)。漏洞影响2026.6.4及之前版本,而2026.7.10和2026.8.18两个后续版本在修复合入前就已经发布,同样存在漏洞。CVE记录在10月6日最后更新时仍然写着"修复PR等待接受",指向的是已被关闭未合并的PR #4890,而不是实际合并的PR #5033。版本范围也停留在2026.6.4,没有覆盖到后续版本。如果你依赖CVE记录来判断自己是否暴露,这个记录会给你一个错误的答案。

真正值得深思的是Mohiuddin在他的分析中揭示的一个细节:mcp-server-fetch代码库里其实已经有一个守卫函数check_may_autonomously_fetch_url(),但提示处理器直接调用了fetch函数,从未调用这个守卫。一个存在于某条代码路径上但不在另一条上的守卫,不是守卫。

修复在10月5日合入modelcontextprotocol/servers仓库的v2/main分支,关闭issue #4838。PR #5033从9月28日社区提交的PR #4890移植而来,核心改动是一个请求钩子:在服务器发出每个请求之前------包括robots.txt检查、页面抓取和每一次重定向跳转------先解析主机名,如果任何解析结果不是公开可路由地址,拒绝请求。拒绝的集合包括回环地址、RFC 1918私有范围、169.254.0.0/16链路本地段(各大云厂商的元数据端点都在这里),以及其他IANA特殊用途范围。

维护者特意把开关做成了一个命令行标志--allow-private-ips,而不是可以通过工具参数传递的配置项。原因很直接:"这样调用工具的模型就不能把它关掉。"

修复随mcp-server-fetch 2026.10.10发布在PyPI上。但它有两个已知限制:DNS重绑定窗口在检查和连接之间仍然存在,服务器无法自己解析的主机名会被放行。这意味着DNS重绑定攻击在理论上仍然可能,只是窗口被大大压缩了。

客户端也有份:rmcp的OAuth元数据SSRF

SSRF不只是服务器的问题。GitHub安全公告GHSA-c9xm-49cp-xcr9描述了rmcp------Rust语言的MCP SDK------中的一个OAuth客户端漏洞。该客户端在处理OAuth授权流程时,会从服务器返回的WWW-Authenticate头中读取resource_metadata URL,然后不加验证地发起请求。这个URL可以指向localhost、私有IP范围或云元数据端点,客户端就会从受害者的网络内部发出请求。

这个漏洞影响rmcp 1.8.0及以下版本,2.0.0已修复。攻击场景很直接:一个恶意MCP服务器在OAuth握手阶段返回一个指向内网的resource_metadata URL,客户端照做,内网请求就这样被发了出去。

MCP官方安全最佳实践指南在2026-07-28规范中有专门的SSRF章节,覆盖了完全相同的流程。它建议对OAuth相关URL强制HTTPS、阻止私有和链路本地范围、验证每个重定向目标、使用出口代理,并警告DNS检查时与使用时之间的间隙。指南还特别提醒:"避免手动实现IP验证。"

AgentCorruption:当IMDS变成凭据提款机

10月8日,Zenity Labs在多伦多SecTor大会上披露了AgentCorruption。这不是MCP协议的漏洞,但它和MCP SSRF共享同一个形状:一个模型选择的请求到达了内部端点,Agent的身份决定了后果有多严重。

Zenity的技术报告称,一个配备了出站HTTP工具的测试Agent被提示请求实例元数据端点169.254.169.254,并成功中转了其执行角色的临时凭据。研究人员随后从AgentCore外部使用了这些凭据。默认执行角色的权限延伸到同一AWS账户和区域中的其他Agent,Zenity还通过其他工具(包括shell工具)复现了元数据访问。

时间线值得注意:Zenity在2025年12月25日报告了元数据问题。AWS回应称,自2026年2月14日起新部署的AgentCore Agent仅使用IMDSv2。Zenity的新闻稿补充说,其测试确认AWS已缩减默认执行角色的权限,包括移除了让Agent调用其他Agent、读取私有对话或访问AWS Secrets Manager中秘密的权限。AWS对定性有异议,其声明称"所描述的行为是记录在案且符合预期的",并建议"客户仅授予其执行角色Agent所需的权限"。

AWS的回应措辞很能说明问题。当一个安全研究员花了近一年时间追踪的漏洞被厂商定性为"记录在案且符合预期"时,潜台词是:我们知道这个风险存在,我们认为这是配置问题而不是产品缺陷。从安全工程的角度看,这种分类方式把责任推给了用户------但默认配置的安全水位恰恰决定了大多数用户的实际安全水位。不是每个使用AgentCore的团队都有安全工程师会去审计IAM角色的权限边界。

防御清单:从零信任到出口代理

把上面所有案例的修复方案提炼成一份可操作的清单,核心原则只有一条:零信任。假设你的Agent、你的MCP服务器、你的客户端、你的运行时环境中至少有一个已经被感染了,然后设计它们之间的每一次交互。

**1. 在网络层强制执行出口控制。**把Agent和MCP服务器放在出口代理或网络策略后面,默认拒绝私有、回环和链路本地目标。MCP规范指向了Smokescreen这样的出口代理。代码内检查仍然有缝隙------fetch服务器自己记录的DNS重绑定限制就是证据。

**2. 用经过审计的库,而不是手写IP验证。**MCP安全最佳实践明确警告不要手动实现IP验证。用你所用语言中维护良好的SSRF防护库,确保它们处理IPv6、DNS重绑定和重定向链。

**3. 收敛Agent权限。**给你的Agent执行角色最小必要权限。如果一个Agent不需要调用其他Agent,就移除这个权限。如果它不需要读Secrets Manager,就不给它这个权限。AWS对AgentCorruption的回应虽然措辞强硬,但这条建议本身是对的。

**4. 加固元数据服务。**在AWS上强制IMDSv2------它需要一个通过PUT请求获取的会话令牌,比IMDSv1的GET请求难伪造得多。其他云厂商有类似的加固方案,确保你的Agent运行环境已经启用。

**5. 把LLM输出当作不可信输入。**McKee的教训值得再引一次:"从LLM传给你的工具的任何东西都应该被当作来自互联网上陌生人的输入来对待,因为在提示注入场景中它就是。"

**6. 升级你的mcp-server-fetch。**如果你运行MCP官方参考实现中的fetch服务器,立即升级到2026.10.10或更高版本。检查你的部署管道中是否固定了旧版本号,Docker镜像也需要重新构建。不要依赖CVE记录的版本范围来判断自己是否暴露------截至10月11日,CVE-2026-104120的记录仍然没有反映已合并的修复。

**7. 审计你的Agent能看到什么。**一次完整的安全审计应该列出每个Agent可以访问的所有URL、所有工具、所有其他Agent。如果一个Agent只需要调用天气API,它就不应该能访问数据库工具。职责分离和最小权限原则在Agent架构中同样适用,甚至更加重要------因为Agent的行为路径比人类操作员更难预测。审计的频率也应该跟上部署的速度------每个季度一次是底线,每个月一次才是目标。

代码实战:用Python构建一个MCP工具级别的SSRF防护钩子

下面这个示例展示如何在一个基于Python的MCP工具服务器中集成SSRF防护。它实现了PR #5033的核心逻辑:在每个出站请求前解析主机名、检查IP范围、验证每个重定向跳转。代码可以直接嵌入任何使用requests或httpx的MCP服务器中。

我们用一个GuardedFetcher类来封装所有出站HTTP调用。每次调用前,它先通过socket.getaddrinfo解析目标主机名,检查每一个解析结果是否落在禁止的IP范围内。如果任何一步解析失败或命中黑名单,请求在发出前就被拒绝。

复制代码
import socket
import ipaddress
import requests
from urllib.parse import urlparse
from typing import Optional, Set

class SSRFGuard:
    """MCP工具级SSRF防护:解析-检查-执行三段式"""

    # 禁止的IP范围:回环、私有、链路本地、元数据端点等
    BLOCKED_RANGES = [
        ipaddress.ip_network("127.0.0.0/8"),      # 回环
        ipaddress.ip_network("10.0.0.0/8"),        # RFC 1918
        ipaddress.ip_network("172.16.0.0/12"),     # RFC 1918
        ipaddress.ip_network("192.168.0.0/16"),    # RFC 1918
        ipaddress.ip_network("169.254.0.0/16"),    # 链路本地 + 云元数据
        ipaddress.ip_network("100.64.0.0/10"),     # CGNAT
        ipaddress.ip_network("198.18.0.0/15"),     # 基准测试
        ipaddress.ip_network("224.0.0.0/4"),       # 组播
        ipaddress.ip_network("240.0.0.0/4"),       # 保留
        ipaddress.ip_network("::1/128"),           # IPv6 回环
        ipaddress.ip_network("fc00::/7"),          # IPv6 ULA
        ipaddress.ip_network("fe80::/10"),         # IPv6 链路本地
        ipaddress.ip_network("ff00::/8"),          # IPv6 组播
    ]

    # 允许的协议
    ALLOWED_SCHEMES: Set[str] = {"http", "https"}

    # 允许的主机名白名单(可选,优先级高于黑名单)
    ALLOWED_HOSTS: Set[str] = set()

    def __init__(self, allow_private_ips: bool = False):
        self.allow_private_ips = allow_private_ips

    def _is_blocked_ip(self, ip_str: str) -> bool:
        """检查IP是否在禁止范围内"""
        try:
            addr = ipaddress.ip_address(ip_str)
            for network in self.BLOCKED_RANGES:
                if addr in network:
                    return True
            return False
        except ValueError:
            # 无法解析为IP,视为可疑
            return True

    def _resolve_and_check(self, hostname: str) -> bool:
        """
        解析主机名并检查所有解析结果。
        返回True表示安全,False表示应拒绝。
        """
        if hostname in self.ALLOWED_HOSTS:
            return True

        try:
            # 获取所有地址族(IPv4 + IPv6)的解析结果
            results = socket.getaddrinfo(
                hostname, None,
                socket.AF_UNSPEC, socket.SOCK_STREAM
            )
        except socket.gaierror:
            # 解析失败:主机名不存在或DNS错误
            # 按PR #5033的设计,无法解析的主机名放行
            # 因为DNS重绑定利用的是解析和连接之间的窗口
            return True

        for family, _, _, _, sockaddr in results:
            ip_str = sockaddr[0]
            if self._is_blocked_ip(ip_str):
                return False

        return True

    def check_url(self, url: str) -> tuple[bool, Optional[str]]:
        """
        检查URL是否安全。
        返回 (is_safe, reason)。is_safe=False时应拒绝请求。
        """
        parsed = urlparse(url)

        # 协议检查
        if parsed.scheme.lower() not in self.ALLOWED_SCHEMES:
            return False, f"协议不允许: {parsed.scheme}"

        hostname = parsed.hostname
        if not hostname:
            return False, "无法提取主机名"

        # 直接IP地址检查(绕过DNS解析)
        try:
            ipaddress.ip_address(hostname)
            # 是纯IP,直接检查
            if self._is_blocked_ip(hostname):
                return False, f"目标IP在禁止范围内: {hostname}"
            return True, None
        except ValueError:
            pass  # 不是IP,是主机名,走DNS解析

        # DNS解析 + 检查
        if not self._resolve_and_check(hostname):
            return False, f"主机名解析到禁止IP: {hostname}"

        return True, None

    def safe_request(self, url: str, method: str = "GET",
                     max_redirects: int = 5, **kwargs) -> requests.Response:
        """
        带SSRF防护的HTTP请求。
        每次重定向都会重新检查目标地址。
        """
        current_url = url

        for redirect_count in range(max_redirects + 1):
            # 每次请求/重定向前都检查
            is_safe, reason = self.check_url(current_url)
            if not is_safe:
                raise SSRFBlockedError(
                    f"SSRF防护拦截: {reason} (URL: {current_url})"
                )

            # 禁止自动跟随重定向,手动处理
            kwargs["allow_redirects"] = False
            resp = requests.request(method, current_url, **kwargs)

            if resp.is_redirect:
                location = resp.headers.get("Location")
                if not location:
                    break
                # 处理相对重定向
                current_url = requests.compat.urljoin(current_url, location)
                continue
            else:
                return resp

        raise SSRFBlockedError(
            f"超过最大重定向次数 ({max_redirects}): {url}"
        )


class SSRFBlockedError(Exception):
    """SSRF防护拦截异常"""
    pass


# --- 使用示例:在MCP工具服务器中集成 ---

def create_mcp_fetch_tool(guard: SSRFGuard):
    """创建一个带SSRF防护的MCP fetch工具"""

    async def fetch_url(url: str) -> str:
        """MCP工具函数:抓取指定URL的内容"""
        try:
            resp = guard.safe_request(url, timeout=10)
            return resp.text[:5000]  # 截断防止内存溢出
        except SSRFBlockedError as e:
            return f"请求被安全策略拦截: {e}"
        except requests.RequestException as e:
            return f"请求失败: {e}"

    return fetch_url


# 初始化(生产环境)
guard = SSRFGuard(allow_private_ips=False)
fetch_tool = create_mcp_fetch_tool(guard)

# 测试
if __name__ == "__main__":
    # 这些应该被拦截
    test_urls = [
        "http://169.254.169.254/latest/meta-data/",  # AWS元数据
        "http://localhost:8080/admin",                  # 回环
        "http://192.168.1.1/router/setup",             # 私有IP
        "http://[::1]:3000/internal",                   # IPv6回环
        "ftp://internal-server/data",                   # 协议不允许
    ]

    print("=== SSRF防护测试 ===")
    for test_url in test_urls:
        is_safe, reason = guard.check_url(test_url)
        status = "✓ 放行" if is_safe else f"✗ 拦截 ({reason})"
        print(f"  {test_url}")
        print(f"    → {status}")

    # 这些应该放行
    safe_urls = [
        "https://api.github.com/repos/modelcontextprotocol/servers",
        "https://pypi.org/pypi/mcp-server-fetch/json",
    ]
    print("\n=== 正常请求测试 ===")
    for safe_url in safe_urls:
        is_safe, reason = guard.check_url(safe_url)
        status = "✓ 放行" if is_safe else f"✗ 拦截 ({reason})"
        print(f"  {safe_url}")
        print(f"    → {status}")

运行这个测试脚本,你会看到所有指向内网、元数据端点和非HTTP协议的URL都被拦截,而正常的公网API请求被放行。关键设计点有三个:第一,每个请求和每次重定向前都重新解析和检查,而不是只在第一次;第二,阻止列表覆盖了IPv4和IPv6的所有危险范围;第三,allow_private_ips是一个启动时的配置项,不是运行时的参数,模型无法通过工具调用来关闭它。

这个实现还有一个已知的限制:DNS重绑定。攻击者可以控制一个域名,在防护检查时解析到一个公网IP,在实际连接时解析到内网IP。缓解这个风险需要在连接层做第二次检查------在TCP连接建立后、发送HTTP请求前,验证实际连接的远端IP仍然安全。这超出了应用层代码的范围,需要在网络栈或出口代理层面解决。

协议层的修复方向

MCP 2026-07-28规范在安全方面已经做了不少工作:发行者验证(RFC 9207)、资源指示器(RFC 8707)、完整JSON Schema 2020-12支持。但SSRF暴露出的是一个更根本的问题------MCP的信任模型假设内部网络中的Agent彼此可信,而现实是任何一个Agent都可能成为攻击的跳板。

短期内,最有效的防御是网络层出口控制加上每个MCP服务器自己的SSRF防护。中期来看,MCP规范可能需要引入Agent身份验证和授权的标准机制,让"Agent A委托Agent B"这个行为本身就需要经过授权,而不是默认信任。长期方向是把零信任原则嵌入协议设计------每个请求都携带可验证的身份声明,每个服务器都验证调用者是否有权请求目标资源。

10月11日发布的mcp-server-fetch 2026.10.10和modelcontextprotocol/servers v1.0.0版本,标志着MCP官方安全响应的第一个重要里程碑。PR #5033的合入过程也暴露了一个治理问题:社区在9月28日就提交了功能相同的PR #4890,但被关闭未合并,直到10月5日维护者才以另一个PR合入了移植版本。对于安全修复来说,这一周的时间差不是小事------在此期间,任何阅读了issue #4838的人都知道漏洞在哪里,但官方版本仍然没有修复。

数字本身也在讲述一个故事。Mohiuddin测试了来自Google、JPMorgan Chase、Weaviate、Rapid7、法国DINUM和美国联邦政府六个不同组织的Agent,它们唯一的共同点就是使用MCP。五个组织确认了漏洞并修复了它。Rapid7的CVE-2026-97228评分只有2.7,属于低危;Google的CVE-2026-14540评分8.0,属于高危。同一个协议,同一个攻击思路,危害等级却相差三倍------这说明风险不只取决于协议本身,更取决于每个实现者在HTTP客户端初始化时做了什么和没做什么。

X41 D-Sec的研究员Markus Vervier说得对------这本质上还是间接提示注入。但Mohiuddin给它起的名字"协议枢转"有价值,因为它指出了一个被忽视的事实:当Agent架构跨越多个协议时,信任和授权在协议边界处丢失了。一个协议检查前门,另一个协议检查后门,没有人看守走廊。

十月的第一周不是终点。随着Agent架构在企业中的部署密度持续增加,类似的漏洞还会冒出来。但至少现在,我们知道了攻击者会走哪条路,也知道该在哪里设卡。如果你正在运行任何MCP服务器,今天就检查你的版本号。如果你在写新的MCP工具,把SSRF防护当作第一个功能来实现,而不是第一个补丁。

相关推荐
Xudde.2 小时前
Mr-Robot 靶机渗透测试(Vulnhub Writeup)
笔记·学习·安全·web安全
聚铭网络2 小时前
你的“三高一弱”排查到位了吗?
安全
howdoyoudo2026067 小时前
AI审计手记 #28:从“暂停训练”到“切断联网”——AI安全控制手段的极限升级
大数据·人工智能·安全·ai·语言模型
xiaohaiAIgeo11 小时前
【2026年】通风柜自动门安全吗?联锁逻辑与防夹设计
人工智能·安全·科普知识
·云扬·11 小时前
Prompt 工程与安全:从 System 设计到注入防御
java·人工智能·安全·ai·prompt
QZ1665609515914 小时前
金融数据流转监测排行:从看见到拦截的距离
网络·安全·网络安全·金融
看着博客敲代码15 小时前
WorkBuddy 接上 Kali MCP 做自动化渗透:信息收集到初步验证的落地记录
linux·人工智能·安全·网络安全·自动化
里昂00717 小时前
发工单前,怎样清理 JSON 日志而不破坏排错信息?
安全