Python subprocess shell=True Command Injection
Setting shell=True in Python's subprocess
module executes commands through the system's underlying command shell
rather than invoking the executable directly. When applications combine
shell=True with unvalidated user input, attackers can
leverage shell metacharacters to alter command logic, execute arbitrary
system commands, and compromise the host environment. This article
explains the technical mechanics behind this vulnerability, demonstrates
how attacks occur, and outlines safe practices to prevent command
injection in Python applications.
How shell=True Works
By default, functions in Python's subprocess module
(such as subprocess.run(), subprocess.Popen(),
and subprocess.check_output()) have
shell=False. In this default mode, Python bypasses the
shell and invokes the target binary directly using low-level operating
system APIs (such as execve on POSIX systems or
CreateProcess on Windows). Arguments are passed directly as
an array of distinct strings.
When shell=True is enabled, Python launches the system
shell first—typically /bin/sh -c on POSIX platforms or
cmd.exe /c on Windows—and passes the entire command string
to the shell process.
The Root Cause of the Vulnerability
The vulnerability arises because the operating system shell parses and interprets reserved metacharacters before executing the command. These characters include:
- Command separators:
;,\n,&,&& - Piping operators:
|,|| - Redirection operators:
>,<,>> - Substitution operators:
`command`,$(command)
If an application constructs a command string by concatenating or formatting user-supplied data, the shell treats any metacharacters inside that input as control instructions rather than literal text.
Example of Vulnerable Code
Consider a script that pings an IP address provided by an external user:
import subprocess
def check_host(user_ip):
# Vulnerable: shell=True combined with dynamic string formatting
command = f"ping -c 1 {user_ip}"
subprocess.run(command, shell=True)If a benign user passes 127.0.0.1, the shell runs:
ping -c 1 127.0.0.1If an attacker inputs 127.0.0.1; cat /etc/passwd, the
shell interprets the semicolon as a command terminator and executes two
separate commands sequentially:
ping -c 1 127.0.0.1
cat /etc/passwdBecause the shell processes the input, the attacker gains the ability to execute any command with the permissions of the running Python process.
How to Prevent Command Injection
Command injection caused by shell=True can be prevented
through proper invocation patterns and input handling.
1. Avoid
shell=True and Pass Arguments as a List
The most effective mitigation is keeping shell=False
(the default) and passing the command and its arguments as a sequence of
strings:
import subprocess
def check_host_safe(user_ip):
# Safe: Direct execution without a shell
subprocess.run(["ping", "-c", "1", user_ip], shell=False)Without an intermediate shell process, characters like
;, |, and && are not
interpreted as command separators. If an attacker inputs
127.0.0.1; cat /etc/passwd, the ping command
receives the entire payload as a single, literal IP address argument,
safely failing with an invalid host error.
2. Use
shlex.quote() When shell=True Is
Unavoidable
If an application requires built-in shell features (such as
environment variable expansion or pipes), any untrusted variables must
be sanitized using shlex.quote() on POSIX systems:
import shlex
import subprocess
def check_host_quoted(user_ip):
safe_ip = shlex.quote(user_ip)
subprocess.run(f"ping -c 1 {safe_ip}", shell=True)shlex.quote() wraps the variable in single quotes and
escapes existing single quotes, ensuring the shell treats the input as a
single literal argument.
3. Enforce Strict Input Validation
Regardless of how commands are run, applications should validate input against strict allowlists (such as validating that an IP address strictly matches a standard IPv4/IPv6 pattern) before passing it to system routines.