详解MCP fetch_url类工具的SSRF攻击风险,指出字符串黑名单和DNS重绑定两大缺陷,提供基于已解析IP类型过滤的防护方案。
如果你的 MCP Server 存在任何接收 URL 并抓取的工具——fetch_url、read_web、summarize_page、或 Webhook 调用器——攻击者只要控制了模型输入,就可以让它指向 http://169.254.169.254/latest/meta-data/ 读取你的云凭证,或指向 http://localhost:6379 探测内网服务。这就是 SSRF,而仅做 Scheme 检查根本拦不住。以下是一种基于解析器(resolver)的防护方案,外加大多数指南都会遗漏的 DNS 重绑定和重定向漏洞。
# BROKEN 1: 用字符串匹配阻止 "localhost"
if "localhost" in url or "127.0.0.1" in url: # 0x7f.1, 127.1, [::1], 2130706433 均可绕过
reject()
# BROKEN 2: 检查主机名,然后按主机名抓取
host = urlparse(url).hostname
if is_private(host): # host 是名称,不是 IP — DNS 可以将它指向任意位置
reject()
requests.get(url) # 再次解析;攻击者的 DNS 第二次返回 169.254.169.254
黑名单注定失败。私有 IP 有几十种写法(小数进制、八进制、IPv6 映射等),你根本枚举不完——应该允许(Allowlist)Scheme,然后按解析后的 IP 类型进行拒绝。
TOCTOU / DNS 重绑定。如果你在验证主机名之后又让 HTTP 客户端再次解析它,攻击者的 DNS 可以在第一次查询时回答"公网",第二次回答"内网"。
import ipaddress, socket
from urllib.parse import urlparse
def safe_target(url: str):
u = urlparse(url)
if u.scheme not in ("http", "https"): # 不允许 file://, gopher://, ftp://
return None
if not u.hostname:
return None
# 自己完成解析,只解析一次
try:
infos = socket.getaddrinfo(u.hostname, u.port or (443 if u.scheme=="https" else 80),
proto=socket.IPPROTO_TCP)
except socket.gaierror:
return None
ip = ipaddress.ip_address(infos[0][4][0])
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return None # 169.254/16, 127/8, 10/8, ::1 等全部覆盖
return str(ip) # 连接到这个 IP,而不是主机名
然后将连接钉在已验证的 IP 上,防止客户端再次解析:
import requests
def fetch(url: str, max_bytes=2_000_000):
ip = safe_target(url)
if ip is None:
raise ValueError("blocked target")
u = urlparse(url)
# 连接到已验证的 IP,同时保留 Host header + SNI 用于 TLS
pinned = url.replace(u.hostname, ip, 1)
r = requests.get(pinned, headers={"Host": u.hostname}, stream=True,
timeout=5, allow_redirects=False) # <-- 手动处理重定向
body = r.raw.read(max_bytes + 1, decode_content=True)
if len(body) > max_bytes:
raise ValueError("response too large")
return body
allow_redirects=False。一次 302 重定向到 http://169.254.169.254/ 可以绕过所有前置检查。手动跟踪重定向,对每个 Location 重新运行 safe_target。
限制响应体大小。无上限的读取会让攻击者用多 GB 响应对你发起 DoS。应该使用流式读取并在达到上限后停止。
设置超时。无超时意味着敌意主机可以永久占用你的 Worker。
import pytest
from mymcp.net import safe_target
@pytest.mark.parametrize("evil", [
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:6379/",
"http://localhost/",
"http://[::1]/",
"http://0177.0.0.1/", # 八进制 127.0.0.1
"http://2130706433/", # 十进制 127.0.0.1
"http://10.0.0.5/",
"file:///etc/passwd",
"gopher://internal/_x",
])
def test_ssrf_blocked(evil):
assert safe_target(evil) is None
def test_public_allowed():
assert safe_target("https://example.com/") is not None
如果 test_ssrf_blocked 对以上所有用例都通过,说明这类攻击已经被封堵了——包括那些能击败所有字符串黑名单的数值编码技巧。
这是我在 MCP Server 加固工具包中维护的六个防护手段之一——SSRF、符号链接安全的路径隔离、仅 argv 的 exec、安全反序列化、输入边界,以及部署前的 grep + 载荷检查清单和测试。想先检查自己的 Server 吗?将它粘贴到免费的 MCP Server Security Scanner——即时发现问题,100% 在浏览器内完成。完整可复制粘贴的防护方案 + 测试套件是 MCP Server Security Hardening Kit($19)。上面的解析器和测试代码免费送你——今天就用上。