Command injection through subprocess in Python: shell=True, shlex and argument injection

Command injection in Python usually comes from one of three mistakes: running a command through a shell with shell=True and a string built from input, quoting that string incorrectly with shlex, or passing an attacker-controlled value as an argument to a program that treats a value beginning with a dash as an option. The first is well known; the second and third catch careful developers who thought they had avoided the problem. This article covers all three, plus cases that look safe and are not, with the fix for each.

The two weaknesses in play are CWE-78: OS Command Injection, where input changes which command runs, and CWE-88: Argument Injection, where input changes the options of a fixed command. Both sit under OWASP's A05:2025 Injection.

Mistake 1: shell=True with a built string

When you pass shell=True, Python hands your string to /bin/sh, which interprets shell metacharacters: ;, |, &, $(), backticks, redirections and more. If any part of that string came from input, those characters let the input run its own commands.

import subprocess

# Vulnerable: input is interpreted by the shell
def make_thumbnail(name):
    subprocess.run(f"convert {name} -resize 100x100 thumb.png", shell=True)

The subprocess documentation states the rule: when the shell is invoked via shell=True, "it is the application's responsibility to ensure that all whitespace and metacharacters are quoted appropriately to avoid shell injection vulnerabilities."

The fix is almost always to not use a shell. Pass the command as a list of arguments, and subprocess executes the program directly with no shell in between:

def make_thumbnail(name):
    subprocess.run(["convert", name, "-resize", "100x100", "thumb.png"])

The docs put the same point positively: without a shell, "all characters, including shell metacharacters, can safely be passed to child processes," because they go to the program as literal argument text rather than being parsed by a shell. You lose shell features such as pipes and globbing, which is the point: if you need a pipeline, build it with subprocess pipes or multiple calls, not by handing a string to sh.

Mistake 2: shlex.quote, used slightly wrong

Sometimes a shell really is needed. shlex.quote() exists for that: it returns a shell-escaped version of a string so it can be used as a single token. The mistake is to then wrap the already-quoted result in your own quotes.

import shlex
import subprocess

value = shlex.quote(user_value)

# Correct: the quoted token stands on its own
subprocess.run(f"printf '%s' {value}", shell=True)

# Wrong: wrapping it in double quotes re-exposes the content
subprocess.run(f'printf "%s" "{value}"', shell=True)

We ran both forms. shlex.quote("$(some-command)") returns the string wrapped in single quotes, which the shell treats as literal in the first call. In the second call, the surrounding double quotes turn the single quotes into literal characters and the shell evaluates what is inside, so the substitution runs. The rule from the shlex docs is to let the quoted value stand as its own token and add no quotes of your own. The docs also warn that shlex.quote() targets POSIX shells and is "not guaranteed to be correct" on non-POSIX shells, so keep it off Windows cmd. The safer path is still the argument list from mistake 1.

Mistake 3: argument injection (a value that starts with a dash)

This is the one that catches people who did everything right about shells. You use an argument list, no shell=True, no metacharacter problem. But a value that begins with - or -- is read by the target program as an option, not as data, because the program parses its own arguments and cannot tell which ones you intended as data.

import subprocess

# Looks safe: a list, no shell. Still exposed if filename starts with a dash.
def show(repo, filename):
    subprocess.run(["git", "-C", repo, "show", f"HEAD:{filename}"])

Here filename is prefixed, so it cannot begin with a dash, which closes that path. But many call sites pass a value straight through. A value like --output=/path or a program-specific flag changes what the command does: writing a file, enabling a feature, reading a different input. Tools commonly abused this way include git, curl, wget, tar, rsync, find, ssh and anything that takes option flags, which is nearly everything.

Two fixes, best used together:

  1. Use the end-of-options marker. Most well-behaved programs treat -- as "everything after this is data, not options." Git documents --end-of-options for exactly this and advises placing a disambiguating -- when handling "random user-input" (git CLI conventions).

    subprocess.run(["git", "-C", repo, "show", "--", filename])
    

    Check the specific tool's manual, because -- support and its meaning vary.

  2. Validate or pin the value's shape. If an argument should be a ref, a filename or a number, enforce that: reject a leading dash, or build the value so it cannot start with one. The f"HEAD:{filename}" form above is an example of pinning a prefix.

ssh deserves a special mention because it is a double trap. Its manual says that if a command is given, "the arguments will be appended to the command, separated by spaces, before it is sent to the server to be executed." So the remote side runs the joined string through a shell. An argument list protects you locally and does nothing remotely: whatever you pass is reassembled and interpreted by the remote shell. Build the remote command as carefully as a local shell=True string, and remember that ssh options on the local side are themselves an argument-injection surface.

Cases that look safe but are not

A few patterns pass a quick review and still carry risk:

  • shlex.split() then join into a shell string. Splitting input into tokens and then re-joining them into a command for shell=True puts the metacharacters right back. Splitting is not sanitizing.
  • A hardcoded program name with an attacker-controlled argument. ["ping", host] looks fixed, but if host can be -f or a flag your platform's ping accepts, it changes behaviour. Argument injection does not need a shell.
  • An "allowlisted" subcommand with an unchecked tail. Checking that the first word is git and passing the rest through unchanged leaves every git option open.
  • Environment-driven execution. Values from environment variables and command-line arguments are attacker-influenced when something upstream builds the environment from input. A PATH or LD_PRELOAD an attacker can set redirects which binary actually runs.
  • os.system, os.popen, commands-style helpers. These always go through a shell. Prefer subprocess with a list.

How to find and confirm these

Grep for shell=True, os.system, os.popen, subprocess. with an f-string or + in the first argument, and shlex. Bandit flags shell=True and the os shell helpers. For argument injection, the harder review is every subprocess call with a list where an element comes from input and is not prefix-pinned or separated by --.

Static search finds the call but not the dataflow: it cannot tell whether the value in host or filename came from a request, a config constant, or a database row, and it does not see a call reached through an alias or built inside a library. Runtime taint tracking answers that. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the command operation itself, so it reports a process launch only when attacker-controlled data actually reached it, with the path from where it entered. Because the check is at the operation, it is indifferent to how the command string was assembled, and a fixed command with no tainted data never fires. See How EyalSec works and the command injection overview.

The habits that prevent almost all of this are small: default to an argument list and no shell, reach for shlex.quote only when a shell is unavoidable and then add no quotes of your own, and treat any value that could start with a dash as an option until you have pinned its shape or passed --. If you want to see which process launches on your own services actually receive untrusted data, you can book a live demo.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo