Python Command Injection
OS command injection refers to a security vulnerability where an attacker can inject and execute arbitrary shell commands by manipulating input that is passed to operating system-level functions.
This type of vulnerability typically arises when user-controlled data is concatenated or passed directly into command execution functions without proper validation or sanitization.
In Python, several built-in functions can be used to execute system commands, and if misused, they can introduce this risk. Common examples include:
os.system()os.popen()subprocess.call()subprocess.Popen()subprocess.run()
These functions provide direct access to the underlying operating system, enabling Python applications to run shell commands and interact with system-level processes.
In most cases where an attacker successfully achieves arbitrary command execution through these functions in web applications, the root cause is insecure handling of user input.
This command injection issue arise when user-supplied data is passed directly into command execution functions without proper validation or sanitization.
os.system()
The system() function, found in Python’s built-in os module, is used to execute system-level commands by passing a string directly to the underlying shell.
This means the entire string is interpreted as a shell command, making it highly dangerous when a user-controlled input is passed to os.system() without proper sanitization or validation.
>>> import os
>>> os.system("whoami")
user
0
>>>
In this case, os.system("whoami") runs the whoami command in the shell, which prints the current user user.
The function returns the command’s exit status which is 0 indicates successful execution.
Based on the shell used (/bin/sh or /bin/bash, etc ..), user-controlled input passed to os.system() can be manipulated to break out of the intended command context and execute arbitrary commands.
In Bash and similar shells, characters like ; && || and backticks among others are commonly used to chain or inject additional commands.
import os
user_input = "127.0.0.1 ; whoami"
os.system(f"ping {user_input}")
This code demonstrates how injecting shell metacharacters into user input can lead to command injection. In this case, the input string:
127.0.0.1 ; whoami
Which is directly passed to os.system(), resulting in the following shell command being executed:
hackpad :: /opt/PyFu/generic-py-fu » python3 os-system-example.py
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
...
user
As we can see, after ping starts, the whoami command is also executed which printing the current user user to the output.
os.popen()
The os.popen() function is another way to execute system commands from within Python. Unlike os.system(), which only returns the exit code of the command, os.popen() opens a pipe to the command’s standard output, allowing the Python script to read the output directly.
However, like os.system(), os.popen() passes the command string to the system shell. This means user-controlled input can lead to command injection if not properly sanitized.
For example:
import os
user_input = "127.0.0.1 ; whoami"
result = os.popen(f"ping -c 1 {user_input}").read()
print(result)
The output:
hackpad :: /opt/PyFu/generic-py-fu 127 » python3 os-popen-example.py
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.056 ms
...
user
Again, after ping starts, the whoami command is also executed, printing the current user user to the output.
subprocess.call()
subprocess.call() is part of Python’s built-in subprocess module, which provides a powerful interface for spawning new processes, running system commands, and interacting with their input/output.
It was introduced as a safer and more flexible alternative to older functions like os.system() and os.popen().
The subprocess.call() function is used specifically to run a command, wait for it to complete, and return its exit status (an integer where 0 usually means success).
It allows you to pass the command as a list of arguments, which avoids many of the risks of shell interpretation making it safer for handling user input.
For example, this code will execute the command ls by creating a new process for ls command:
import subprocess as sp
sp.call("ls")
# Output :
file1.txt
In this example, subprocess.call() runs the ls command, waits for it to complete, and then returns the command’s exit status.
The actual output of the command (file1.txt) is printed directly to the terminal because stdout is not captured, it’s passed through to the parent process (Python) by default.
If we try to execute the command ls -la using subprocess.call() by passing it as a single string, it will raise an error.
This happens because subprocess.call() interprets the entire string ("ls -la") as the name of a single executable, which doesn’t exist.
import subprocess as sp
sp.call("ls -la")
# Output: FileNotFoundError Exception
To fix this, developers should pass the command and its arguments as a list of strings, so that subprocess can correctly identify the executable (ls) and its arguments (-la):
import subprocess as sp
sp.call(["ls", "-la"])
#Output:
total 8
drwxrwxr-x 2 hacker hacker 4096 May 29 17:13 .
drwxr-x--- 51 hacker hacker 4096 May 29 17:14 ..
-rw-rw-r-- 1 hacker hacker 0 May 29 17:13 file1.txt
subprocess-call.py
This will correctly execute the ls -la command, listing files in the current directory in long format, and return the exit status of the command.
While it’s safe to run commands in this way, attackers can still exploit subprocess.call() to achive code execution if the parameter shell=True is passed during executing a command, this will treat the whole string as a shell command and not as a binary and it’s arguments.
While it’s generally safe to run commands using subprocess.call() with a list of arguments, it becomes dangerous when the parameter shell=True is used.
When shell=True is passed, subprocess.call() no longer treats the command as a binary and its arguments, it instead hands the entire string over to the system shell, just like os.system().
import subprocess as sp
sp.call("ls -la", shell=True)
#Output:
drwxrwxr-x 2 hacker hacker 4096 May 29 17:33 .
drwxr-x--- 51 hacker hacker 4096 May 29 17:33 ..
-rw-rw-r-- 1 hacker hacker 0 May 29 17:13 file1.txt
-rw-rw-r-- 1 hacker hacker 54 May 29 17:33 subprocess-call.py
So if the user control part of the command passed to subprocess.call() while using shell=True, the attacker can pass metacharacters like ;, &&, |, or backticks and inject a command:
import subprocess as sp
uinput = "127.0.0.1; whoami"
command = "ping -c 1 %s" % uinput
sp.call(command, shell=True)
In this example, the shell executes both ping and whoami commands. The output will display the results of the ping followed by the current system user, demonstrating that the injected whoami command was successfully executed:
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.057 ms
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.057/0.057/0.057/0.000 ms
user
subprocess.call() is considered an older method for executing system commands in Python. While it’s still supported and used in many codebases, it has been largely superseded by more flexible and modern alternatives like subprocess.run()
subprocess.run()
subprocess.run() is the recommended modern way to execute system commands in Python, introduced in Python 3.5 to unify and simplify subprocess management.
It combines the functionality of older functions like subprocess.call(), subprocess.check_call(), and subprocess.check_output() into a single, more flexible interface.
At its core, subprocess.run() executes a command, waits for it to complete, and returns a CompletedProcess object, which contains information such as the command’s arguments, return code, stdout, and stderr.
Just like subprocess.call(), when using subprocess.run(), it is safer to pass the command as a list of arguments to avoid invoking the system shell, which minimizes the risk of command injection.
For example:
>>> import subprocess
>>>
>>> result = subprocess.run(["ls", "-la"])
total 20
drwxrwxr-x 3 hacker hacker 4096 Jun 14 14:11 .
drwxrwxr-x 12 hacker hacker 4096 Jun 1 15:43 ..
-rw-rw-r-- 1 hacker hacker 878 May 31 16:36 app.py
-rw-rw-r-- 1 hacker hacker 94 May 31 15:54 Dockerfile
drwxrwxr-x 2 hacker hacker 4096 Jun 14 14:11 __pycache__
>>> print(result)
CompletedProcess(args=['ls', '-la'], returncode=0)
>>>
In this example, subprocess.run() executes ls -la, waits for it to finish, and returns a CompletedProcess object.
By default, the output of the command is displayed directly in the console, because
stdoutandstderrare not captured.
Similar to the other subprocess functions, the command injection risk appears when developers use shell=True.
When shell=True is passed, the entire command string is handed over to the system shell, making it vulnerable to injection if any part of it is influenced by user-controlled input.
For example:
import subprocess
user_input = "127.0.0.1; whoami"
subprocess.run(f"ping -c 1 {user_input}", shell=True)
In this example, the user input is directly embedded into the command string passed to the shell. As a result, the shell interprets and executes both the ping and whoami commands.
hackpad :: /opt/PyFu/generic-py-fu » python3 subprocess-run.py
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.061 ms
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.061/0.061/0.061/0.000 ms
user
By default,
subprocess.run()behaves much safer than older functions, but improper use ofshell=Truecombined with unsanitized input can still result in arbitrary command execution.
Why command injection matters from an offensive security perspective
Command injection is the bug I am happiest to find, because it skips every intermediate step and hands me arbitrary execution in the OS context the application runs as. There is no gadget to build and no namespace to escape; the moment my metacharacters reach the shell, I am running my commands next to theirs. From there it is whoami, then a reverse shell, then whatever the service account can touch, and the impact is bounded only by how that process was deployed.
What makes this class so reliable in Python is that the dangerous path is also the convenient one. Building a command with an f-string and handing it to os.system or subprocess.run(..., shell=True) is shorter than assembling an argument list, so it shows up constantly in glue code: ping/traceroute utilities, image and PDF conversion wrappers, git and archive operations, and “run this admin script” features. I look for the shell, then walk the string backward to the nearest attacker source.
These are the tells I grep for on an assessment:
shell=Trueanywhere. Everysubprocess.call,subprocess.run,subprocess.Popen, orcheck_outputcarryingshell=Trueis a candidate. The flag turns a safe argument list back into a shell string.os.system(andos.popen(. These always invoke the shell. If any part of the argument is f-string,%,.format(), or+with a variable, I assume injectable until proven otherwise.- Command strings built by concatenation or interpolation.
f"ping {host}","convert %s out.png" % name, andcmd + user_valueare the signature shape, regardless of which function consumes them. shlex.quoteused as the only defense. Quoting helps for a single argument but is routinely misapplied across whole command strings or skipped on one field, so its presence flags code worth reading closely.- Filenames, hostnames, and “options” passed through unchanged. Values that look harmless to the developer are exactly where
;,|,$(), and backticks slip in.
For defenders the takeaway is direct: the presence of a shell in the execution path is the vulnerability, so the audit question is never “is this input sanitized” but “why is a shell here at all”.
Mitigation
The fix is to never pass user input through a shell. Avoid shell=True, os.system, and os.popen entirely and call programs with an argument list (subprocess.run([...], shell=False)) so the arguments reach the executable directly and shell metacharacters lose all meaning. Where a value must name a program or an option, validate it against a strict allowlist rather than trying to escape or blocklist dangerous characters, and run the process with the least privilege possible so that even a successful injection is contained.