FlawOpen

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

● CWE-918 · High
FlawOpen Security Research

Wazuh MCP SSRF in blueteam_check_webshell (GHSA-pw2j-pj4h-f5vg)

A hostname bypassed Wazuh-MCP-Server’s literal-IP check before a forensic tool fetched it. The GHSA assigns High severity and CWE-918; no CVE is assigned. Fixed in commit 2bbfe12.[1], [2], [3]

💡 Plain English Explainer (ELI5)

A receptionist checks that a visitor’s badge is not already on a blacklist, then lets the visitor choose which office to enter. Checking only the printed badge misses a visitor whose name points to a restricted office. The fix checks where the name resolves and makes the connection use that checked destination.[1], [3]

Core Concepts & Subsystem Terms

Server-side request forgery (SSRF)
A server makes a network request to a destination influenced by its caller. If destination checks are incomplete, the server may reach networks the caller cannot reach directly.[4], [1]
DNS resolution and rebinding
A hostname is translated to one or more IP addresses. If validation checks only the hostname text, or the HTTP client resolves it again later, the address used for the connection may not be the address that was checked.[1], [3]
Destination pinning
The validated IP address is bound to the outgoing connection while the hostname remains available for HTTP host and TLS verification. This closes the validation-to-connection gap for that request.[3]

Framework mappings

Step-by-Step Attack Flow

Step 1

An incident-derived URL reaches the checker

The affected blueteam_check_webshell tool accepts a URL so an analyst can inspect a suspected webshell address from forensic data. The advisory scopes this finding to that tool, not every Wazuh-MCP-Server capability.[1]

Step 2

The validator checks only literal IP text

The vulnerable WebshellCheckInput.validate_url parses the hostname and passes it to ipaddress.ip_address. A literal address can be checked, but a DNS name raises ValueError and is accepted as a hostname; no A or AAAA lookup happens at this boundary.[2], [1]

Step 3

The HTTP client resolves and follows the destination

The validated URL was later passed to curl, which performed its own DNS lookup. The advisory also documents automatic redirect following without re-validating each destination. This creates SSRF exposure when the service can reach an internal address and the tool is callable with an attacker-influenced URL. The actual impact depends on network reachability and tool access.[1], [3]

Step 4

The patch validates and pins resolved addresses

Commit 2bbfe12 resolves both IPv4 and IPv6 records, rejects non-public addresses unless the hostname is explicitly configured in ALLOWED_INTERNAL_DOMAINS, and pins the selected address for the request. The patched fetch path does not automatically follow redirects. The GHSA assigns High severity and CWE-918, reports no CVE, and lists no package version range.[1], [3]

Source Code: Flaw vs. Secure Implementation

The vulnerable excerpt reproduces the relevant WebshellCheckInput.validate_url logic from commit 6bd1fa8; surrounding imports and class fields are omitted. It rejects literal non-public IPs, but a hostname raises ValueError, is treated as a domain, and passes without DNS resolution. The hardened excerpt reproduces _pinned_curl_args from fix commit 2bbfe12. The patched validator also calls _host_pins; the fetch loop supplies these --resolve arguments to curl and validates each redirect destination before the next hop. The patch allows internal destinations only for explicitly configured domains. These are focused excerpts, not a drop-in replacement for the full URL-fetching path.[2], [3]

Source excerpt: mcp_server/tools/webshell_check.py — WebshellCheckInput.validate_url (6bd1fa8) and _pinned_curl_args (2bbfe12)

UNPATCHED FLAW
@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}")
    # Reject private/reserved IPs in URL host
    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
HARDENED SECURE PATCH
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

Engineering & System Hardening Checklist

Sources

← AI Security Hub Directory →