FlawOpen

flawopen.com/AI Security/wazuh-mcp-server-webshell-check-ssrf-ghsa-pw2j-pj4h-f5vg

● CWE-918 · 高危
安全研究 · FlawOpen

Wazuh MCP Webshell 检查工具的 SSRF 漏洞 — GHSA-pw2j-pj4h-f5vg

取证工具访问主机名之前,该主机名绕过了 Wazuh-MCP-Server 对字面 IP 的检查。GHSA 标为高危并关联 CWE-918;尚未分配 CVE。修复提交为 2bbfe12。[1], [2], [3]

💡 通俗易懂的原理解析 (ELI5)

前台先检查访客证件是否在黑名单上,再让访客选择办公室。只检查印在证件上的号码,无法发现这个名字实际指向受限房间。修复会检查名称解析到哪里,并让连接固定使用已检查的地址。[1], [3]

核心概念与专有名词

服务器端请求伪造(SSRF)
服务器向由调用方影响的目标发送网络请求。目标检查不完整时,服务器可能访问调用方无法直接访问的网络。[4], [1]
DNS 解析与重绑定
主机名会解析为一个或多个 IP 地址。如果只检查主机名文本,或 HTTP 客户端稍后再次解析,实际连接地址可能与已检查地址不同。[1], [3]
目标地址固定
将已验证的 IP 绑定到出站连接,同时保留主机名用于 HTTP 和 TLS 校验,从而消除检查与连接之间的差异。[3]

框架映射

攻击执行流程分解

步骤 1

取证数据中的 URL 进入检查工具

受影响的 blueteam_check_webshell 工具接收 URL,供分析人员检查疑似托管 webshell 的地址。公告将问题限定在该工具,并未称 Wazuh-MCP-Server 的所有功能都受影响。[1]

步骤 2

验证器只检查字面 IP

有缺陷的 WebshellCheckInput.validate_url 解析主机名后调用 ipaddress.ip_address。字面 IP 可被检查;DNS 名称会触发 ValueError,随后在没有查询 A 或 AAAA 记录的情况下被接受。[2], [1]

步骤 3

HTTP 客户端自行解析并跟随重定向

之后,验证通过的 URL 被交给 curl,由它再次执行 DNS 解析。公告还说明自动跟随重定向时不会重新验证目标。如果服务器可以访问内部地址,且 URL 参数受不可信输入影响,就会形成 SSRF 风险。实际影响取决于网络可达性和工具访问权限。[1], [3]

步骤 4

补丁验证并固定解析出的地址

提交 2bbfe12 同时解析 IPv4 和 IPv6 记录;除非主机名明确列入 ALLOWED_INTERNAL_DOMAINS,否则拒绝非公网地址,并将选定地址固定到请求中。修复后的请求路径不会自动跟随重定向。GHSA 标记为高危并关联 CWE-918;没有 CVE 或软件包版本范围。[1], [3]

源代码对比:漏洞与安全实现

有缺陷的片段复现了提交 6bd1fa8 中 WebshellCheckInput.validate_url 的相关逻辑;省略了 imports 和类字段。它会拒绝非公网字面 IP,但把主机名触发的 ValueError 当作域名并直接放行,不进行 DNS 解析。修复片段复现了修复提交 2bbfe12 中的 _pinned_curl_args。修复后的验证器还会调用 _host_pins;请求循环将这些 --resolve 参数传给 curl,并在每次跳转前验证重定向目标。只有显式配置的域名才允许访问内部地址。这些是聚焦的源码片段,并非完整 URL 请求流程的替代实现。[2], [3]

源码摘录: mcp_server/tools/webshell_check.py — 验证 6bd1fa8;修复 2bbfe12

存在漏洞的实现
@field_validator("url")
@classmethod
def validate_url(cls, v: str) -> str:
    v = v.strip()
    if not v.startswith(("http://", "https://")):
        raise ValueError(f"URL must start with http:// or https://, got: {v!r}")
    # 拒绝 URL 主机中的私有/保留 IP 地址。
    from urllib.parse import urlparse
    import ipaddress
    host = urlparse(v).hostname
    if host:
        try:
            ip = ipaddress.ip_address(host)
        except ValueError:
            ip = None  # not an IP - domain name, allowed
        if ip is not None and (ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local):
            raise ValueError(f"URL host is a private/reserved IP: {host}. This tool only accepts public hosts.")
    return v
加固后的安全修复
def _pinned_curl_args(url: str) -> tuple[list[str], str | None]:
    """Build curl --resolve args for an already-validated destination."""
    import ipaddress
    u = urlparse(url)
    host = u.hostname
    if not host:
        return [], "URL has no host."
    try:
        ipaddress.ip_address(host)
        return [], None  # Literal IP: no DNS lookup to rebind.
    except ValueError:
        pass
    port = u.port or (443 if u.scheme == "https" else 80)
    ips, err = _host_pins(host, _allowed_internal_domains())
    if err:
        return [], err
    args: list[str] = []
    for ip in ips:
        addr = f"[{ip}]" if ":" in ip else ip
        args += ["--resolve", f"{host}:{port}:{addr}"]
    return args, None

工程与系统安全加固清单

参考来源

← AI Security Directory →