skip to content

In subprocess.run, what changes between passing a list and passing shell=True?

level: juniorimportance: must knowfreq 75%

answer

  1. Ask who parses the string
  2. One element, one argv entry
  3. shell=True means /bin/sh -c
  4. A list with shell=True feeds $0, $1
  5. os.system and os.popen always shell out

basics

~20 s

With a list, CPython execs the program directly and each element becomes exactly one argv entry, so nothing tokenises it. With shell=True the whole string goes to /bin/sh -c, where ; | $() and word splitting are live syntax.

solid answer

~50 s

`subprocess.run(["cp", src, dst])` resolves `args[0]` as a path or on `PATH`, execs it, and hands the remaining elements to the child as `argv[1:]` byte for byte. No parser sits between your Python values and that vector, so a value containing `;`, `|`, a newline or `$(...)` is simply a filename with odd characters. With `shell=True` on POSIX, CPython instead runs `/bin/sh -c <string>`, and the shell does quote removal, word splitting, globbing, substitution and command separation on the whole string, so any interpolated value is code in a second language. A Python-specific trap: with `shell=True` a *list* is not what people expect — `args[0]` is the command string and the remaining elements become the shell's own positional parameters `$0`, `$1`, and so on. `os.system` and `os.popen` have no list form at all and always run a shell.

code

python · 5 lines
python
import subprocess

name = "report.csv; echo INJECTED"
subprocess.run(["echo", name])          # one literal argument
subprocess.run("echo " + name, shell=True)  # the shell runs a second command

go deeper

for a junior

Recall the rule and the reason: pass a list, leave shell at its default, and remember that with shell=True a shell parses your whole string. Be ready to say what a semicolon in a filename does under each form.

for a middle

Explain the mechanics: PATH resolution of args[0], exec handing the rest through as argv, and /bin/sh -c doing splitting, globbing and substitution. Know the list-plus-shell=True trap and that os.system and os.popen have no list form.

for a senior

Show that dropping the shell is necessary but not sufficient — talk about who controls args[0], PATH resolution, and values that arrive as clean arguments but are read as options. Be ready to point at how you would audit every call site in a service.

for a principal

Own the policy: a lint rule or review gate that bans shell=True and os.system outside an audited allow-list, a single wrapper that every service calls, and a clear account of the few places where a shell is genuinely justified and what guards them.

## Two different things happen `subprocess.run` and `subprocess.Popen` accept `args` either as a sequence of strings or as one string, and the `shell` keyword decides who interprets it. The two modes are not variations on a theme; they run different machinery. ### The list form: no interpreter at all With a sequence and the default `shell=False`, CPython takes `args[0]` as the program — used directly if it contains a path separator, otherwise searched for on `PATH` — forks, and execs it, passing the remaining elements to the child as `argv[1:]` exactly as you wrote them. Nothing tokenises, expands or splits anything. If `dst` is the string `"report.csv; rm -rf /tmp/x"`, the child receives one argument that happens to contain a semicolon and a space. The semicolon is not a separator because there is no parser present for it to be a separator *to*. That is why the list form removes a whole bug class rather than shrinking it. There is no escaping to get right, no character set to enumerate, and the behaviour does not change when someone adds an unusual byte to a filename. ### The shell form: a second language With `shell=True` on POSIX, CPython execs `/bin/sh` with `-c` and your command string (`executable=` picks a different shell). The shell then performs quote removal, word splitting on `IFS`, pathname expansion, parameter and command substitution, redirection, and command separation on that entire string. Every attacker-influenced substring becomes shell syntax: `;` and `&&` chain commands, `|` builds a pipeline, backticks and `$(...)` substitute output, `>` truncates files. Even with no attacker, word splitting alone breaks any path containing a space. ### The list-plus-`shell=True` trap Mixing the two is a classic misread. On POSIX, `subprocess.run(["report.sh", "a;b"], shell=True)` does *not* run `report.sh` with one argument. It runs `/bin/sh -c "report.sh" "a;b"`, so `args[0]` is the command string and every later element becomes a positional parameter of the shell itself — `$0`, `$1` — visible to the command string only if it references them. Candidates who believe the list still isolates arguments while `shell=True` is set have the worst of both: shell parsing plus arguments that silently vanish. ### `os.system` and `os.popen` Neither takes an argument list; both hand a string to a shell. `os.system` returns a wait status rather than output, and `os.popen` returns a file object; in Python 3 it is implemented on top of `subprocess`. Code that builds a command for either is one f-string away from injection, and the fact that `os.system` returns "only a number" is no mitigation — the command still ran with your process's privileges. ### A plain string with `shell=False` The fourth combination is worth knowing because it produces a confusing error rather than a security hole. On POSIX, passing a string with the default `shell=False` treats the whole string as the program name: `subprocess.run("ls -l")` looks for an executable literally called `ls -l` and raises `FileNotFoundError`. Nothing is split on spaces, because splitting is a shell behaviour and no shell is present. The fix is a list, or `shlex.split` applied to a command template you control — never `str.split`, which mishandles quotes and breaks on any path containing a space. On Windows the same call behaves differently again, because the platform builds processes from a command-line string in the first place, which is one more reason not to lean on the string form. ### `shell=False` is necessary, not sufficient Dropping the shell fixes the parsing hole, not every hole. Two remain. First, `args[0]` chooses what to execute: if the program name is attacker-influenced, exec runs whatever they named, and `PATH` search decides which copy — pin an absolute path, or resolve deliberately with `shutil.which` over a `PATH` you control. Second, a value that reaches the child as one clean argument can still be read as an *option* by that program: a filename beginning with `-` or `--` is a flag. Where a program supports the `--` end-of-options separator, use it, and validate values that must be paths. ### Replacing the shell you thought you needed Most `shell=True` calls exist for a feature the standard library already offers. Globbing is `glob.glob`; redirection is a file object passed as `stdout` or `stderr`; the working directory is `cwd=`; environment control is `env=`. When a shell genuinely is required, keep the command template program-authored and constant, and pass every dynamic value either through `shlex.quote` or as a positional parameter to a fixed script. ### What the interviewer is reading This is the fastest signal available on whether you think about injection. The strong answer names who parses what, states the list form as the default, and treats `shell=True` as a deliberate, quoted exception rather than a convenience.

  • What happens if you call subprocess.run(["ls -l"]) with the default shell=False?
    It raises `FileNotFoundError`. The whole string `"ls -l"` is taken as the program name, and no executable by that name exists — the space is part of the filename, not a separator. Either write the list properly as `["ls", "-l"]`, or turn a command string into a list with `shlex.split`, which applies POSIX quoting rules instead of a naive split on spaces.
  • If the program name itself comes from user input, does shell=False make the call safe?
    No. `args[0]` decides what runs, so an attacker who controls it can execute any program reachable on `PATH` with your process's privileges. Constrain the program to an allow-list of known commands, map user input to a fixed absolute path rather than passing it through, and resolve with `shutil.which` against a `PATH` you set explicitly via `env=` rather than whatever the parent inherited.
  • When is shell=True actually the right choice?
    When you genuinely need shell features — a pipeline, a shell builtin, redirection or expansion — and the command template is written by you rather than assembled from input. Even then, interpolate values through `shlex.quote`, or better, pass them as positional parameters to a constant command string so the shell never parses them as syntax. Reaching for `shell=True` merely to avoid writing a list is never the right reason.

saying these in an interview costs you the question

  • Says shell=True is fine once you strip semicolons
  • Thinks a list still isolates arguments when shell=True is set
  • Calls os.system safe because it returns only a status code
  • Believes shell=False makes any user input harmless
  • Splits a command string on spaces instead of using shlex.split
  • Assumes quoting with str.replace is equivalent to a list

context