Security·Attack Paths·Lateral Movement

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:

  1. Attacker crafts a prompt that causes the LLM agent to call an MCP tool with a URL parameter pointing to the metadata service
  2. The MCP server fetches the URL (SSRF) and returns the response to the agent
  3. The agent, following its instructions, returns the response to the attacker's prompt
  4. Attacker now has a temporary IAM access key and secret

Defenses:

// 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:

// 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.