Lateral movement from a compromised MCP server: attack paths and defenses
Compromising an MCP server is often not the attacker's end goal — it's the starting point. From a server with SSRF, RCE, or prompt injection vulnerabilities, an attacker can pivot to cloud IAM credentials via the metadata service, reuse stored tokens across your entire infrastructure, abuse implicit trust between co-deployed MCP servers, and exfiltrate data through the server's authorized API connections. This guide maps four lateral movement paths and the specific controls that cut each one.
Path 1: SSRF to cloud metadata service
The cloud instance metadata service (AWS: 169.254.169.254, GCP: 169.254.169.254 or metadata.google.internal) is the most valuable SSRF target on any cloud VM or container. A successful request to http://169.254.169.254/latest/meta-data/iam/security-credentials/ returns temporary IAM credentials with the EC2 instance role's permissions — which often includes access to S3 buckets, other secrets, and database endpoints.
The attack chain:
- Attacker crafts a prompt that causes the LLM agent to call an MCP tool with a URL parameter pointing to the metadata service
- The MCP server fetches the URL (SSRF) and returns the response to the agent
- The agent, following its instructions, returns the response to the attacker's prompt
- Attacker now has a temporary IAM access key and secret
Defenses:
- Block
169.254.169.254at the network layer (Kubernetes NetworkPolicy + Cilium deny rule) - Use IMDSv2 on EC2 (requires a PUT request for the token — not exploitable via simple HTTP GET SSRF)
- Apply URL allowlist validation in any tool that accepts a URL parameter
// URL validation that blocks SSRF to metadata and private ranges
import { parse } from "url";
const BLOCKED_HOSTS = new Set([
"169.254.169.254",
"metadata.google.internal",
"100.100.100.200",
]);
const PRIVATE_CIDRS = [
/^10\.\d+\.\d+\.\d+$/,
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
/^192\.168\.\d+\.\d+$/,
/^127\.\d+\.\d+\.\d+$/,
];
function assertSafeUrl(raw: string): void {
let parsed: URL;
try { parsed = new URL(raw); }
catch { throw new ValidationError("fetch_url", "url", "must be a valid URL"); }
if (!["https:"].includes(parsed.protocol)) {
throw new ValidationError("fetch_url", "url", "must use HTTPS");
}
const host = parsed.hostname;
if (BLOCKED_HOSTS.has(host)) {
throw new ValidationError("fetch_url", "url", "disallowed host");
}
if (PRIVATE_CIDRS.some(r => r.test(host))) {
throw new ValidationError("fetch_url", "url", "private network ranges not allowed");
}
}
Path 2: token reuse across tools
Many MCP servers store a single API key or database connection and share it across all tools. If one tool has an SSRF or injection vulnerability that leaks the shared credential, the attacker gains access to everything the server can do — not just the vulnerable tool's capability.
The defense is per-tool or per-operation credentials. In practice, this means:
- Read-only tools use read-only credentials
- Write tools use write credentials that are separate from read credentials
- Destructive tools use separate credentials that require MFA or approval
- Database connections are scoped to the minimum SQL privileges needed by each tool
// Per-tool credential scoping
const credentials = {
// list_issues, search_docs: read-only GitHub token
readonly: process.env.GITHUB_TOKEN_READONLY,
// create_issue, add_comment: write-scoped token (separate PAT)
write: process.env.GITHUB_TOKEN_WRITE,
// delete_*: never stored — requires approval + runtime token fetch
};
// DB per-tool users with row security policies
const dbConnections = {
reader: createPool({ connectionString: process.env.DB_READER_URL }), // SELECT only
writer: createPool({ connectionString: process.env.DB_WRITER_URL }), // INSERT/UPDATE
admin: null, // No standing admin connection — fetched on demand from Vault
};
Path 3: inter-server trust exploitation
In multi-agent architectures, multiple MCP servers often run in the same Kubernetes namespace and communicate over internal DNS. If server A trusts all traffic from within the namespace and server B is compromised, an attacker can use B to call A without any authentication — because A assumes anything from within the namespace is authorized.
The defense is explicit authentication even for internal calls. Do not rely on network position as an authentication substitute:
// Internal MCP-to-MCP call with explicit service token
async function callInternalServer(
serverName: string,
toolName: string,
args: unknown,
): Promise<unknown> {
// Fetch a short-lived service token from Vault
const serviceToken = await vault.getServiceToken(`mcp-server-${serverName}`);
const resp = await fetch(`http://${serverName}.mcp-workloads.svc.cluster.local:3000/tool/${toolName}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${serviceToken}`,
// Propagate the original caller's identity for audit trail
"X-Caller-Id": getCurrentCallerId(),
"X-Request-Id": crypto.randomUUID(),
},
body: JSON.stringify(args),
});
if (!resp.ok) throw new ToolError(toolName, `Internal server call failed: ${resp.status}`);
return resp.json();
}
Path 4: database connection pool reuse
If your MCP server shares a database connection pool across requests, an SQL injection vulnerability in one tool can read data that other tools would normally protect. The attack doesn't need to steal credentials — it just executes a query that bypasses the intended data access scope.
Row-level security in PostgreSQL provides an enforcement layer that survives even successful SQL injection in application logic:
# PostgreSQL row security policy for MCP server access
-- Each tool runs as a specific DB user with limited permissions
-- list_issues tool: can only SELECT from issues table
GRANT SELECT ON issues TO mcp_reader;
ALTER TABLE issues ENABLE ROW LEVEL SECURITY;
-- Row policy: mcp_reader can only see issues for their org
CREATE POLICY issues_org_isolation ON issues
FOR SELECT
TO mcp_reader
USING (org_id = current_setting('app.current_org_id')::uuid);
-- Set the org at connection time (from authenticated caller identity)
-- In Node.js:
await db.query("SET app.current_org_id = $1", [caller.orgId]);
Defense-in-depth matrix
| Attack path | Primary control | Detection |
|---|---|---|
| SSRF to cloud metadata | NetworkPolicy block + URL allowlist | Cilium flow logs for 169.254.x.x attempts |
| Shared credential reuse | Per-tool minimum-scope credentials | AWS CloudTrail: anomalous API calls from MCP role |
| Inter-server implicit trust | Explicit service-token auth for all internal calls | Audit log: missing X-Caller-Id on internal call |
| DB connection pool injection | Row-level security + per-tool DB users | pg_audit: unexpected queries from mcp_reader user |
For related patterns, see Kubernetes NetworkPolicy for MCP workloads, MCP server sandboxing and isolation, and API key management and minimum scope.