# mcp-server-pentest

Pentest an MCP server for authentication bypass, confused-deputy SSRF, and tool-argument injection - black-box from its URL, deeper with repo or host access. Use to assess an MCP server's exposure.

- **Kind:** skill
- **Source:** https://github.com/forefy/.context
- **Page:** https://forefy.com/skills/92a75792-fe1e-4ad7-9b07-a92cc8906a66
- **API (JSON + files):** https://forefy.com/api/asr/92a75792-fe1e-4ad7-9b07-a92cc8906a66

---

## SKILL.md

---
name: mcp-server-pentest
description: Pentest an MCP server for authentication bypass, confused-deputy SSRF, and tool-argument injection - black-box from its URL, deeper with repo or host access. Use to assess an MCP server's exposure.
compatibility: Checks 7 and 9 want an OAST listener (interactsh-client or Burp Collaborator); see the ssrf-oob skill
---

## Contents
- Scope & authorization (blast-radius labels)
- Input modes: what a URL gets you, what a repo adds
- Passive:
  1. Inventory & transport fingerprint
  2. Destination-shaped tool parameters
  3. Credential scope & identity blast radius
  4. Config & secret storage *(repo/host only)*
- Active:
  5. Session forgery, unauthenticated access & reachability
  6. Confused-deputy SSRF via caller-chosen destination
  7. Tool-argument injection
- False-positive gates
- Runnable snippets
- Output

## Scope & authorization

Only run against an MCP server you own or are contractually engaged to test. Note the unusual consent shape: these checks speak JSON-RPC to the server directly, which **bypasses the agent's tool-approval UI**. Anything the operator believed a human would confirm before it ran will run unattended here. Confirm that is in scope before check 5.

Blast-radius labels:

- **Passive (1, 2, 3, 4)** - reads the server's own manifest, config and schemas. Check 1 completes the MCP handshake, which is one benign request the server is built to answer; nothing else leaves your machine.
- **Active (5, 6)** - live JSON-RPC to the target. Check 6 also reaches third parties (your OAST host, cloud metadata endpoints).
- **Aggressive (7)** - sends injection payloads through tools that may execute them. Run last, in a low-traffic window.

## Input modes

The protocol is self-describing, so a URL alone carries most of the surface: `tools/list` returns a machine-readable schema of every tool and parameter before you send a single payload.

| Input | Runs | Notes |
|---|---|---|
| **Remote URL** *(default)* | 1, 2, 5, 6, 7, partial 3 | Everything except config storage. |
| **+ repo or host access** | adds 4, completes 3, sharpens 7 | Source tells you which tools shell out, so check 7 targets instead of fuzzes; env/config tells you the real grants behind check 3. |

Transport decides more than source does. A **stdio** server has no URL and no listener: spawn it yourself, run 1, 2, 3, 4, 7, and skip 5 and 6 unless it also serves HTTP.

## Passive

### 1. Inventory & transport fingerprint

Complete the handshake and record what the server admits to. POST JSON-RPC 2.0 `initialize` (`Accept: application/json, text/event-stream`), then `notifications/initialized`, then `tools/list`, `resources/list`, `prompts/list`.

Record: transport (streamable HTTP, legacy SSE, stdio), `serverInfo` name and version, negotiated `protocolVersion`, declared `capabilities`, bind address, whether the handshake required a credential (401 vs 200), and whether the server issues an `Mcp-Session-Id` response header and in what format. That format is the input to check 5.

Not a finding on its own. One thing worth noting while you are here: a server exposing a single `*_api_request` tool instead of ten typed tools has chosen passthrough, which is exactly what check 2 looks for.

### 2. Destination-shaped tool parameters

Over every `inputSchema` in `tools/list`, flag parameters that let the caller choose where the server connects or what it sends. Report an INSIGHT (this is the gate for check 7, not a finding by itself).

- **Destination names**: `(?i)(^|_)(url|uri|endpoint|base[_-]?url|host|hostname|origin|server|target|address|addr|domain|proxy|callback|webhook|redirect)($|_)`
- **Passthrough shape**: a tool taking `method` **and** `path` **and** `body` or `headers` together. This is full request forgery, not just destination choice, and it is the more dangerous of the two - caller-controlled method plus headers is what turns IMDSv2 back into IMDSv1 in check 7.
- **Header override**: an object-typed parameter named `headers`, `extra_headers`, `additional_headers`.

Record the tool name, the parameter, and whether the schema constrains it (`enum`, `const`, or a `pattern` anchored to a fixed host).

### 3. Credential scope & identity blast radius

An MCP server is an identity broker: it turns a caller's instruction into an action performed with the server's own credential and network position. Two questions.

**Does one identity serve every caller?** If the server holds a single service-account token and does not derive identity per caller, every caller inherits the full grant. Black-box tell: two different callers see the same `tools/list` and get the same results on an identity-revealing tool (`whoami`, `get_current_user`, `list_projects`).

**Is that identity wider than the tools need?** Classify each tool read / write / admin from its name and description, then, with explicit authorization, invoke the lowest-impact **write** tool and see whether it succeeds. A read-only integration whose token can write is over-scoped.

With repo or host access, skip the inference and read the configured token's actual grants.

Report a finding when: one shared identity backs all callers, **or** the identity's grants exceed the union of what the exposed tools require. Severity rises sharply if check 5 shows the server is reachable unauthenticated, because then the grant is public.

### 4. Config & secret storage *(repo/host only)*

Inspect `claude_desktop_config.json`, `.mcp.json`, `.vscode/mcp.json`, container env, compose files, and any systemd unit. Run the secret patterns from `../../applicative-pentest/webapp-probe/references/secret-regexes.md` over them rather than duplicating the set here, and apply that file's placeholder gate - `${VAR}` and `<your-token>` are not secrets.

Report a finding when: a long-lived credential sits in plaintext in a config the agent host reads, the file is group- or world-readable (check the mode), or a token is committed to the repo. Note that plaintext env in MCP config is the ecosystem norm, so rate it on the credential's blast radius from check 3, not on its mere presence.

## Active

### 5. Session forgery, unauthenticated access & reachability - *active*

The MCP spec treats `Mcp-Session-Id` as a state reference for resumption, not a credential, and tells servers not to authenticate with it. Servers that validate the *format* and not the caller accept a session that was valid but never issued. Test three variants in order, stopping at the first that succeeds:

- **a. No credential at all.** Full handshake with no auth header. If `tools/list` returns a catalog, the server is simply unauthenticated and you do not need forgery.
- **b. Fabricated session.** Mint a value in the format check 1 observed (commonly `mcp-session-<uuidv4>`), send it as `Mcp-Session-Id` on `tools/list`, then on a read-only `tools/call`.
- **c. Borrowed session.** If another caller's ID is observable (logs, a shared proxy, a referrer), replay it. This is fixation/hijack rather than forgery.

Record where the server is reachable from as part of the same check: a loopback binding limits the blast radius of everything above, a routable binding makes it public. Reachability is not a separate finding, it is the multiplier on this one.

Report a finding when: any variant returns a tool catalog or executes a call. Rate the two outcomes separately - a catalog is information disclosure, an executed `tools/call` is full authentication bypass. Severity is **critical** when the executed tool then acts with the server's stored credential, which is the chain the Grafana MCP advisory describes: the caller never held the service-account token, and the upstream API still recorded the request as that account.

### 6. Confused-deputy SSRF via caller-chosen destination - *active, active-3rdparty*

For each parameter from check 2, in this order:

1. **OOB canary first.** Point the parameter at a unique subdomain of your OAST host. This proves the server connects without touching anything internal. Hand the listener work to the `ssrf-oob` skill rather than rebuilding it; encode the tool and parameter name into the subdomain label so the hit attributes itself.
2. **Credential-attachment test.** Aim the parameter at a host you control and read what arrived. Does the server attach its own token to a destination that is not the configured upstream? A server that strips credentials on host change (Grafana already did) still has an SSRF primitive, but credential-attached SSRF is a full tier more severe. Establish which one you have before rating it.
3. **Internal reach.** Loopback, private and link-local ranges, and cloud metadata:
   - AWS IMDSv2 is a two-step and needs method plus header control: `PUT http://169.254.169.254/latest/api/token` with `X-aws-ec2-metadata-token-ttl-seconds: 21600`, then `GET .../latest/meta-data/iam/security-credentials/` carrying `X-aws-ec2-metadata-token`. A passthrough tool that exposes both is what makes IMDSv2 no better than v1 here.
   - GCP `http://metadata.google.internal/computeMetadata/v1/` with `Metadata-Flavor: Google`.
   - Azure `http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01` with `Metadata: true`.

Report a finding when: the OAST listener records a hit, the response body carries metadata or internal-service content, or an internal-only host answers. Severity: critical for credential-attached SSRF or a returned cloud credential.

### 7. Tool-argument injection - *aggressive*

Target tools whose name or description implies a shell, a filesystem, a query language, or template rendering. With repo access, grep for `child_process`, `spawn`, `exec`, `subprocess`, `os.system`, `eval`, and unnormalized path joins first, and test only those tools - blind fuzzing across every tool is noisy and mostly hits validation.

Payload classes: command separators (`; id`, `$(id)`, backticks, `| id`), path traversal (`../` chains, absolute paths, null byte), SQL/NoSQL where a query is accepted, and template markers where output is rendered.

Report a finding when: the OAST listener fires, or the response carries the artifact (command output, file contents, an evaluated expression).

## False-positive gates

Each of these has produced a wrong severity in practice. Apply them before reporting.

- **Check 5 must run from an unauthenticated network position.** Many deployments front the server with a gateway that injects auth or strips headers. Testing from inside that boundary turns a normal configuration into a false critical. State in the report which network position produced the result.
- **Check 2 is not check 6.** A `url` parameter constrained by `enum`, `const`, or a host-anchored `pattern` is not a primitive, and a `url` used as a *data* field (a dashboard link stored as text) is never fetched at all. Confirm the server actually connects before promoting an insight to a finding.
- **Check 7 rejects echoed errors.** MCP servers commonly return the underlying tool's error verbatim, so an error message containing your payload proves parsing, not execution. Require the artifact.
- **Check 6 allowlists need three attempts before you call them sound.** Resolved-IP literal, a redirect to an internal host, and DNS rebinding. A single blocked hostname proves nothing.
- **Check 3 needs evidence, not tool names.** `delete_dashboard` in a catalog is not proof the token can delete. Confirm with an authorized write probe or the config.

## Runnable snippets

Set `URL` to the server endpoint. Streamable HTTP shown; for legacy SSE, GET `/sse` first and POST to the session endpoint it returns.

```bash
# 1 handshake + surface dump
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
curl -sik -X POST "$URL" -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' -d "$INIT" | tee /tmp/init.txt
SID=$(grep -i '^mcp-session-id:' /tmp/init.txt | cut -d' ' -f2 | tr -d '\r')
echo "issued session: ${SID:-none}"
curl -sk -X POST "$URL" -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' ${SID:+-H "Mcp-Session-Id: $SID"} \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' > /tmp/tools.json
```
```bash
# 2 flag destination-shaped and passthrough parameters
python3 - /tmp/tools.json <<'PY'
import sys,json,re
d=json.load(open(sys.argv[1]))
dest=re.compile(r'(?i)(^|_)(url|uri|endpoint|base[_-]?url|host|hostname|origin|server|target|address|addr|domain|proxy|callback|webhook|redirect)($|_)')
for t in d.get("result",{}).get("tools",[]):
    props=(t.get("inputSchema") or {}).get("properties",{}) or {}
    hits=[p for p in props if dest.search(p)]
    passthru=[k for k in ("method","path","body","headers") if k in props]
    if hits: print(f"{t['name']}: destination params {hits}"
                   f"{' CONSTRAINED' if any(k in props[h] for h in hits for k in ('enum','const','pattern')) else ''}")
    if len(passthru)>=3: print(f"{t['name']}: PASSTHROUGH {passthru} <- method+header control")
PY
```
```bash
# 5 session forgery - (a) no creds, then (b) a session that was never issued
FAKE="mcp-session-$(uuidgen | tr 'A-Z' 'a-z')"
for hdr in "" "Mcp-Session-Id: $FAKE"; do
  echo "--- ${hdr:-no session header}"
  curl -sk -o /tmp/r -w '%{http_code}\n' -X POST "$URL" -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' ${hdr:+-H "$hdr"} \
    -d '{"jsonrpc":"2.0","id":3,"method":"tools/list"}'
  grep -q '"tools"' /tmp/r && echo "  CATALOG RETURNED - session accepted as authorization"
done
```
## Output

Two-part ledger, then a verdict:

- **Passive** - per class (1, 2, 3, 4): what the surface is, with the tool and parameter names behind each insight. Say which input mode you had, since 4 and half of 3 are unreachable from a URL.
- **Active** - per check (5, 6, 7): check / blast radius / run? / result, with the confirming request for every hit, and the network position check 5 ran from.

Report each check's true status - run, not-run, not-applicable-for-this-transport, or blocked. A stdio target legitimately skips 5 and 6; say so rather than reporting them clean. A clean check 6 only means the parameters from check 2 did not reach out, and if check 2 found nothing there was nothing to test.

Reference: Pillar Security, "Valid but never issued: session spoofing and SSRF in Grafana MCP" (CVE-2026-19516), which is the source for checks 5 and 6.

