# PYTHON-LANG-SEC-072: Paramiko exec_command() Usage

> **Severity:** MEDIUM | **CWE:** CWE-78 | **OWASP:** A03:2021

- **Language:** Python
- **Category:** Python Core
- **URL:** https://codepathfinder.dev/registry/python/lang/PYTHON-LANG-SEC-072
- **Detection:** `pathfinder scan --ruleset python/PYTHON-LANG-SEC-072 --project .`

## Description

paramiko's SSHClient.exec_command() executes a shell command on the remote server via
the SSH exec channel. The command is passed to the remote shell (/bin/sh by default)
which interprets shell metacharacters. When the command string is constructed from
untrusted user input, an attacker can inject shell metacharacters to execute additional
commands on the remote host.

The risk pattern is the same as local OS command injection but the impact is remote:
the injected commands execute on the SSH server with the SSH user's privileges on
that system.

SSH exec_command() sends a single command to the remote shell without an interactive
session. Use paramiko's invoke_shell() only when an interactive session is genuinely
required, and use exec_command() with validated, sanitized arguments for automation.


## Vulnerable Code

```python
import socket
import paramiko
import multiprocessing.connection

# SEC-072: paramiko exec_command
stdin, stdout, stderr = client.exec_command("ls -la")
```

## Secure Code

```python
import paramiko
import shlex

# INSECURE: exec_command with user-controlled content
# ssh.exec_command(f"grep {user_pattern} /var/log/app.log")

# SECURE: Validate input and use shlex.quote() for shell argument safety
import re

def search_remote_log(ssh_client: paramiko.SSHClient,
                      pattern: str, log_file: str) -> str:
    SAFE_PATTERN = re.compile(r'^[a-zA-Z0-9_\-\.@]+$')
    if not SAFE_PATTERN.match(pattern):
        raise ValueError(f"Invalid search pattern: {pattern}")
    quoted_pattern = shlex.quote(pattern)
    quoted_log = shlex.quote(log_file)
    _, stdout, stderr = ssh_client.exec_command(
        f"grep -F {quoted_pattern} {quoted_log}"
    )
    output = stdout.read().decode()
    error = stderr.read().decode()
    if error:
        raise RuntimeError(f"Command error: {error}")
    return output

# SECURE: Use an allowlist of pre-defined commands
ALLOWED_COMMANDS = {
    "disk_usage": "df -h /",
    "memory_info": "free -m",
    "cpu_info": "nproc",
}

def run_allowed_command(ssh_client: paramiko.SSHClient, cmd_name: str) -> str:
    if cmd_name not in ALLOWED_COMMANDS:
        raise ValueError(f"Command not allowed: {cmd_name}")
    _, stdout, _ = ssh_client.exec_command(ALLOWED_COMMANDS[cmd_name])
    return stdout.read().decode()

```

## Detection Rule (Python SDK)

```python
from rules.python_decorators import python_rule
from codepathfinder import calls, QueryType


@python_rule(
    id="PYTHON-LANG-SEC-072",
    name="Paramiko exec_command",
    severity="MEDIUM",
    category="lang",
    cwe="CWE-78",
    tags="python,paramiko,ssh,command-execution,CWE-78",
    message="paramiko exec_command() detected. Ensure command is not user-controlled.",
    owasp="A03:2021",
)
def detect_paramiko_exec():
    """Detects paramiko SSHClient.exec_command usage."""
    return calls("*.exec_command")
```

## How to Fix

- Validate all user-controlled values that appear in exec_command() arguments against strict allowlists or regex patterns before use.
- Use shlex.quote() to properly quote arguments that must be included in shell commands executed via exec_command().
- Prefer a fixed allowlist of pre-defined commands over dynamic command construction; store full commands as constants and look them up by validated name.
- Capture and validate the exit code and stderr from exec_command() to detect errors and potential injection attempts.
- Use SFTP for file operations instead of exec_command() with cp/mv/cat to eliminate shell involvement entirely.

## Security Implications

- **Remote Command Injection:** Shell metacharacters in user-controlled content injected into exec_command() execute
additional commands on the remote SSH server. Semicolons, pipes, backticks, and
dollar signs are all interpreted by the remote shell, enabling injection of arbitrary
remote commands.

- **Remote System Compromise:** Injected commands execute with the SSH user's privileges on the remote server. If the
SSH user has sudo rights or the server runs other sensitive services, command injection
can escalate to full remote system compromise.

- **Lateral Movement:** SSH automation scripts that execute commands on multiple servers are particularly
dangerous when compromised. Command injection in one server's exec_command() call
can affect all servers in the automation pool.

- **Sensitive Output Capture:** If the injected command outputs sensitive data (credentials, private keys, configuration)
and stdout is captured, the attacker can extract this data through the command output
returned to the Python application.


## FAQ

**Q: Does exec_command() use a shell or exec() directly?**

exec_command() sends an SSH "exec" request to the remote server, which is typically
handled by the SSH server's shell (sshd runs the command via /bin/sh -c "command").
This means shell metacharacters in the command string are interpreted. Unlike
subprocess.run() with a list, there is no equivalent shell-free mode for exec_command().


**Q: How does shlex.quote() help with exec_command() security?**

shlex.quote() wraps the argument in single quotes and escapes any internal single
quotes, producing a shell-safe string. Using shlex.quote() on each user-supplied
argument before including it in the command string prevents most shell injection.
However, it does not protect against argument injection (flags starting with "-")
or against choosing the command itself from user input.


**Q: Is there a way to use paramiko without a shell for executing commands?**

paramiko's Transport and Channel API allows sending "exec" requests without shell
interpretation in theory, but the SSH server-side daemon (sshd) typically still
passes the command to a shell via -c. For truly shell-free execution, use SFTP
for file operations or consider a dedicated protocol (gRPC, REST over SSH tunnel)
for application commands.


**Q: What is the difference between exec_command() and invoke_shell()?**

exec_command() opens a new channel, runs a single command, and closes. It is non-
interactive and suitable for automation. invoke_shell() opens an interactive terminal
emulator session that persists until explicitly closed. exec_command() is preferred
for automation; invoke_shell() has additional complexity and fewer use cases.


**Q: How do I handle long-running commands with exec_command()?**

exec_command() returns immediately with stdin, stdout, stderr channels. For long-
running commands, use stdout.channel.recv_exit_status() to wait for completion
and check the exit code. Set a timeout using the get_transport().global_request_timeout
or read with a timeout to prevent indefinite blocking.


**Q: Should I use exec_command() or SFTP for remote file operations?**

Use SFTP (ssh.open_sftp()) for file operations such as reading, writing, listing
directories, and transferring files. SFTP does not involve a shell and is not
vulnerable to shell injection. Use exec_command() only when you need to run actual
commands and cannot accomplish the task via SFTP.


## References

- [CWE-78: OS Command Injection](https://cwe.mitre.org/data/definitions/78.html)
- [paramiko docs: SSHClient.exec_command()](https://docs.paramiko.org/en/stable/api/client.html#paramiko.client.SSHClient.exec_command)
- [OWASP OS Command Injection Defense Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html)
- [OWASP Top 10 A03:2021 Injection](https://owasp.org/Top10/A03_2021-Injection/)
- [Python docs: shlex.quote()](https://docs.python.org/3/library/shlex.html#shlex.quote)

---

Source: https://codepathfinder.dev/registry/python/lang/PYTHON-LANG-SEC-072
Code Pathfinder — Open source, type-aware SAST with cross-file dataflow analysis
