Use subprocess with shell=False
Use subprocess with shell=False always to prevent shell injection. Hands-on steps, troubleshooting, and what to study next.
Focus: use subprocess with shell=false always
You’ve probably written subprocess.call("ls -l", shell=True) at some point — it works, it’s short, and you moved on. But that one line is a loaded gun in your codebase. When user input sneaks into that string, your process can execute arbitrary commands on your server. The fix is simple, yet so often overlooked: use subprocess with shell=False always. This lesson shows you why shell=False is your default, how to make it a habit, and what pitfalls to avoid — so you can write system calls that are both safe and maintainable.
The problem this lesson solves
when you pass a command string to the shell, you inherit all the power and all the danger of /bin/sh. If any part of that string is influenced by user input — even indirectly through environment variables or file names — an attacker can inject their own commands. This is called shell injection, and it’s been a top vulnerability in OWASP rankings for years.
Consider this line in a backup script:
import subprocess
filename = input("Backup file: ")
subprocess.call(f"tar -czf /backups/{filename}.tar.gz /data", shell=True)
If a user enters backup.tar.gz; rm -rf /, your command becomes tar -czf /backups/backup.tar.gz; rm -rf / — and you’ve just wiped the server. That’s the pain: every string interpolated into a shell=True call is a potential exploit. You don’t need a full-blown web app to hit this; one CLI script, one cron job, one CI pipeline is enough.
Beyond security, shell=True brings quoting headaches, platform-dependent behavior (Windows’ cmd.exe differs from POSIX sh), and slower performance because it spawns an extra process. The solution is to bypass the shell entirely.
Core concept / mental model
Think of subprocess as a remote control. With shell=False, you press the exact button you want: you send a list of arguments directly to the operating system’s process launcher. With shell=True, you hand the remote to a translator (the shell) who interprets your wishes — and might misunderstand or be tricked.
The mental model: shell=False is like calling a friend directly on the phone (secure, direct, no middleman). shell=True is like sending a message through a gossipy assistant who repeats your words with added flavor — sometimes dangerous flavor.
Formally, subprocess.run(args, shell=False) means args is a sequence of strings (or a single string on POSIX, but we’ll stick to lists). Each element becomes one argument to the executable. No special characters like ;, |, $(), or backticks are interpreted — they’re just literal characters in an argument. This is the core security guarantee: no command injection possible.
With shell=True, args is a single string that the shell parses. That’s when the translator is in play, and every metacharacter becomes a potential exploit.
How it works step by step
- Import subprocess — always use
import subprocess, notfrom subprocess import *. - Build a list of arguments — start with the executable path (or command name, if you trust
PATH), then each argument as a separate string. - Call
subprocess.run()withshell=False— this is the default, but be explicit for readability. - Capture or check the result — use
capture_output=Trueto get stdout/stderr, andcheck=Trueto raise an exception on non-zero exit. - Handle errors — use
try/exceptaroundsubprocess.CalledProcessError.
For example, to list files with ls -l, you write:
import subprocess
result = subprocess.run(["ls", "-l"], capture_output=True, text=True, shell=False)
print(result.stdout)
Notice: (1) the command is a list, (2) shell=False is explicit, (3) text=True gives you string output instead of bytes. Each step is intentional and safe.
Hands-on walkthrough
Let’s build a small Python script that safely runs grep on a user-supplied file, demonstrating the secure pattern.
Step 1: Secure command execution with shell=False
import subprocess
import sys
def search_file(filepath, pattern):
"""Run grep safely, no shell involved."""
try:
result = subprocess.run(
["grep", "-n", pattern, filepath],
capture_output=True,
text=True,
check=True,
shell=False
)
return result.stdout
except subprocess.CalledProcessError as e:
if e.returncode == 1:
return "No matches found."
return f"Error: {e.stderr}"
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python safe_grep.py <file> <pattern>")
sys.exit(1)
print(search_file(sys.argv[1], sys.argv[2]))
Run it:
$ echo -e "apple\nbanana\ncan't" > fruits.txt
$ python safe_grep.py fruits.txt 'an'
banana
can't
Even if the pattern includes shell metacharacters, it’s passed literally: pattern = "; rm -rf /" is just a literal string to grep — no command runs.
Step 2: Why shell=False blocks injection — a test
import subprocess
try:
# Malicious input tries to chain commands
subprocess.run(["echo", "hello; touch /tmp/pwned"], shell=False, check=True)
except subprocess.CalledProcessError as e:
print("Command failed, no injection:", e)
# Check the file was not created
import os
print("File exists?", os.path.exists("/tmp/pwned"))
Output:
hello; touch /tmp/pwned
File exists? False
The command simply outputs the literal string hello; touch /tmp/pwned — no harm done.
Step 3: Safely handle piped commands
When you need a pipe like ls | wc -l, don’t use shell=True. Instead, run each command separately and feed stdout to stdin:
import subprocess
ls = subprocess.run(["ls"], capture_output=True, text=True, check=True, shell=False)
wc = subprocess.run(["wc", "-l"], input=ls.stdout, capture_output=True, text=True, check=True, shell=False)
print(wc.stdout) # number of files
Single quotes inside a command are treated as literal characters, not quoting mechanisms.
Compare options / when to choose what
| Approach | Use case | Security | Complexity |
|---|---|---|---|
subprocess.run([...], shell=False) |
Default for any external command | Safe — no injection | Simple, clear |
subprocess.run(str, shell=True) |
Rarely needed; only when you must use shell features (like globbing or env var expansion) | Unsafe — requires careful quoting | Higher risk |
subprocess.run(list, shell=True) |
Never (it’s confusing — list is joined into a string, then shell-parsed) | Unsafe | Confusing |
os.system() |
Legacy, avoid | Unsafe, uses shell | Simple |
Direct subprocess.Popen |
For complex bidirectional I/O | Can be safe if shell=False |
Higher complexity |
Recommendation: Always start with shell=False. If you need shell features, find an alternative: use glob for wildcards, os.environ for environment variables, or break the pipeline into separate run calls. Only if you have an absolute necessity (and write a test proving it) should you consider shell=True — and then validate all inputs with an allowlist.
Troubleshooting & edge cases
Problem: FileNotFoundError when using a command name.
If you use a simple name like python and it’s not in PATH, you get an error. Fix: provide the full path or ensure PATH is set.
subprocess.run(["/usr/bin/python3", "--version"], shell=False)
Problem: Handling commands that require quoting (e.g., paths with spaces).
With shell=False, you pass the path as a single argument — no quoting needed:
subprocess.run(["cat", "/path/with spaces/file.txt"], shell=False)
Problem: Interpolating shell variables like $HOME.
With shell=False, you must expand them yourself:
import os
path = os.path.expanduser("~/file.txt")
subprocess.run(["cat", path], shell=False)
Problem: Piping output to another program.
As shown earlier, connect stdout to stdin manually.
Problem: Windows compatibility.
Command names may vary (dir vs ls). Use Python’s shutil.which() or use built-in modules like os.listdir() instead of shell commands.
Edge case: Detaching processes.
If you need a long-running process, use Popen with shell=False and manage stdin/stdout carefully.
What you learned & what's next
You now understand use subprocess with shell=False always — you can explain why shell injection happens, how shell=False neutralizes it, and how to apply it in practice with lists of arguments, capturing output, and piping between commands. You’ve seen hands-on examples and how to avoid common pitfalls.
Your next lesson in the Secure development track will build on this foundation — likely covering input validation or command injection defenses in web frameworks. You’ll be ready to apply the same principle in different contexts.
Remember: every time you type subprocess, your default reaction should be shell=False. It’s a tiny habit with a massive security payoff.
Practice recap
Write a small Python script that runs ping with a user-supplied hostname using shell=False. Test it with a harmless host, then try a malicious input like 8.8.8.8; whoami and observe that nothing extra runs. Next, modify your script to capture and print both stdout and stderr — you'll see the shell metacharacters are just treated as literal characters.
Common mistakes
- Using
shell=Truejust to run a simple command — even if input isn't user-controlled today, it might be tomorrow. Always useshell=False. - Passing a full command string to
subprocess.run()withshell=False— this only works on POSIX and is still fragile; always pass a list of arguments. - Forgetting to expand environment variables like
$HOME— withshell=False, they're not expanded, so you must useos.path.expanduser()oros.environ. - Using
subprocess.Popenoros.systeminstead of the higher-levelsubprocess.run— stick torununless you need fine-grained control.
Variations
- Use
shlex.split()to safely parse user-provided command strings into a list, then pass it withshell=False— but only if you must accept a string for legacy reasons. - For shell features like wildcards or pipes, use Python's
globmodule or chain multiplesubprocess.runcalls instead of relying on the shell. - If you're on POSIX and need a shell feature, consider using
subprocess.runwithshell=Truebut only after strict input validation and quoting viashlex.quote()— though this is a last resort.
Real-world use cases
- A backup script that takes a database name from user input —
shell=Falseprevents an attacker from appending; drop databaseto dump commands. - A CI pipeline that runs tests with a user-supplied test file path — safe execution ensures no shell injection from the filename.
- A web application's backend that invokes image conversion tools like
ffmpegwith user-uploaded file names — usingshell=Falseblocks command injection attempts via file paths.
Key takeaways
- Always call
subprocess.run()withshell=False— it's the default, but make it explicit for clarity and safety. - Pass arguments as a list of strings, never a single pre-joined string.
- Shell metacharacters are inert with
shell=False— no chance of command injection. - Pipes and wildcards need Python equivalents — chain commands or use
globinstead ofshell=True. - Handle output and errors with
capture_output=True,text=True, andcheck=Truefor robust scripts. - When you truly need shell features, document why and add extra validation — but avoid it if at all possible.
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.