Safeguard
Security Guides

Preventing Command Injection in Python

Every time Python code shells out with user input, an attacker gets a vote on what the shell runs. The fix is almost always to stop using the shell at all.

Priya Mehta
Security Researcher
5 min read

Command injection is what happens when data your program treats as an argument gets reinterpreted by a shell as a command. In Python it has a signature look: subprocess.run(..., shell=True) with a string built from user input, or a lingering os.system(f"..."). The vulnerability is severe because success is not data theft; it is arbitrary code execution as your service account.

The core idea: arguments, not a command line

The root cause is asking a shell to parse a string. The shell treats ;, |, &, $(), backticks, and newlines as control characters, so any of them inside your interpolated value can start a new command. If you never invoke a shell, none of those characters mean anything special, and the whole class of bug disappears.

That is why the primary fix is not "escape the input." It is "do not use a shell." Pass your command as a list of arguments and let the operating system execute the program directly:

import subprocess

filename = request.args["file"]

# VULNERABLE - a shell parses the whole string
subprocess.run(f"convert {filename} out.png", shell=True)

# SAFE - no shell; filename is a single argument, never parsed
subprocess.run(["convert", filename, "out.png"], shell=False)

In the safe version, a filename of x.jpg; rm -rf / is passed to convert as one literal argument. There is no shell to see the semicolon, so nothing runs but convert.

Why shell=True keeps coming back

Developers reach for shell=True for three reasons: they want a pipe, they want glob expansion, or they copied a command that used shell features. Each has a shell-free answer.

For pipes, wire the processes together in Python instead of asking the shell to:

# Instead of shell=True with "grep foo | wc -l"
grep = subprocess.Popen(["grep", pattern], stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE)
wc = subprocess.Popen(["wc", "-l"], stdin=grep.stdout,
                      stdout=subprocess.PIPE)
grep.stdin.write(data)
grep.stdin.close()

For globbing, use the standard library rather than the shell:

import glob
matches = glob.glob("/data/*.csv")  # expansion in Python, not a shell

The functions to grep for

Some entry points are shells no matter what you pass them. Audit for these and treat any user-influenced argument as suspect:

  • os.system() always runs a shell. Replace it with subprocess.run([...]).
  • os.popen() runs a shell. Same replacement.
  • subprocess.*(shell=True) runs a shell. Drop shell=True and pass a list.
  • commands.* (legacy) runs a shell.

There is a subtle sibling: eval() and exec() on user input are code injection in the Python interpreter itself, not the OS shell, but the harm is identical. Never evaluate user-supplied strings as code.

When you truly need a shell

Occasionally a legitimate operational tool must build a command line, for example handing a string to a remote system over SSH. In that narrow case, quote every interpolated value with shlex.quote, and understand you are now responsible for correctness:

import shlex

safe = shlex.quote(user_value)   # wraps and escapes for POSIX sh
remote_cmd = f"backup --target {safe}"

Note that shlex.quote targets POSIX shells; it is not a Windows cmd.exe solution, and it does not help if the value crosses two levels of shell. Prefer avoiding the shell entirely; reach for quoting only when you cannot.

Argument injection: the subtle cousin

Dropping the shell closes the big hole, but a related bug survives it. Even with a clean argument list, if an attacker controls a value that lands in a flag position, they can inject options the program honors. A filename beginning with a dash can be read as an option rather than a path, and some tools have flags that write files or execute hooks. Two habits defuse this: use the -- separator so everything after it is treated as positional arguments, and validate values that could masquerade as flags.

# The `--` tells the program: no more options after this point
subprocess.run(["grep", pattern, "--", user_path], shell=False)

This matters most for powerful tools like git, find, and archive utilities, where a single unexpected flag changes behavior dramatically.

Reduce the blast radius

Even with clean call sites, assume something will slip and limit what a compromised subprocess can do:

  • Run the service as an unprivileged user, never root.
  • Set an absolute path for the executable so PATH cannot be hijacked.
  • Constrain the working directory and pass an explicit, minimal env.
  • In containers, drop capabilities and mount the filesystem read-only where possible.

Checklist

CheckSafe pattern
Shelling outsubprocess.run([...], shell=False)
Legacy callsReplace os.system / os.popen
PipesChain Popen objects in Python
Globbingglob.glob, not the shell
Dynamic codeNever eval/exec on input
Unavoidable shellshlex.quote every value
RuntimeUnprivileged user, absolute paths, minimal env

How Safeguard helps

Command injection is a code-level flaw, so the fixes above are yours to make; a dependency scanner will not rewrite a shell=True call. Safeguard's contribution is twofold. First, the risk is not only in your code: image-processing wrappers, PDF toolchains, and build utilities frequently shell out on your behalf, and when one of those packages carries a command-injection CVE, our software composition analysis flags it in your tree. Second, for the harder question of whether a flagged issue is actually exploitable in your service, Griffin AI traces reachability and explains the path, so you can tell a real subprocess risk from an unused code path. You can run either check locally before you push with the Safeguard CLI.

Get started

Purge shell=True from your call sites first, then let Safeguard watch the libraries that shell out for you. Create a free project at app.safeguard.sh/register and follow the docs at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.