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 withsubprocess.run([...]).os.popen()runs a shell. Same replacement.subprocess.*(shell=True)runs a shell. Dropshell=Trueand 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
PATHcannot 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
| Check | Safe pattern |
|---|---|
| Shelling out | subprocess.run([...], shell=False) |
| Legacy calls | Replace os.system / os.popen |
| Pipes | Chain Popen objects in Python |
| Globbing | glob.glob, not the shell |
| Dynamic code | Never eval/exec on input |
| Unavoidable shell | shlex.quote every value |
| Runtime | Unprivileged 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.