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

Sponsored

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

  1. Import subprocess — always use import subprocess, not from subprocess import *.
  2. Build a list of arguments — start with the executable path (or command name, if you trust PATH), then each argument as a separate string.
  3. Call subprocess.run() with shell=False — this is the default, but be explicit for readability.
  4. Capture or check the result — use capture_output=True to get stdout/stderr, and check=True to raise an exception on non-zero exit.
  5. Handle errors — use try/except around subprocess.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=True just to run a simple command — even if input isn't user-controlled today, it might be tomorrow. Always use shell=False.
  • Passing a full command string to subprocess.run() with shell=False — this only works on POSIX and is still fragile; always pass a list of arguments.
  • Forgetting to expand environment variables like $HOME — with shell=False, they're not expanded, so you must use os.path.expanduser() or os.environ.
  • Using subprocess.Popen or os.system instead of the higher-level subprocess.run — stick to run unless you need fine-grained control.

Variations

  1. Use shlex.split() to safely parse user-provided command strings into a list, then pass it with shell=False — but only if you must accept a string for legacy reasons.
  2. For shell features like wildcards or pipes, use Python's glob module or chain multiple subprocess.run calls instead of relying on the shell.
  3. If you're on POSIX and need a shell feature, consider using subprocess.run with shell=True but only after strict input validation and quoting via shlex.quote() — though this is a last resort.

Real-world use cases

  • A backup script that takes a database name from user input — shell=False prevents an attacker from appending ; drop database to 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 ffmpeg with user-uploaded file names — using shell=False blocks command injection attempts via file paths.

Key takeaways

  • Always call subprocess.run() with shell=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 glob instead of shell=True.
  • Handle output and errors with capture_output=True, text=True, and check=True for robust scripts.
  • When you truly need shell features, document why and add extra validation — but avoid it if at all possible.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.