OWASP defines command injection plainly: it's an attack where the goal is "execution of arbitrary commands on the host operating system via a vulnerable application," and it happens the moment unsanitized input from a form, cookie, or HTTP header reaches a system shell. In Python, that shell handoff is one line away in either direction — os.system(cmd) or subprocess.run(cmd, shell=True) — both of which pass a string to /bin/sh on Linux or cmd.exe on Windows for interpretation before execution. A filename parameter like bob.txt becomes bob.txt;id in OWASP's own PHP illustration of the pattern, and the same trick works identically against a Python Flask endpoint that shells out to ping, convert, or tar with a user-supplied argument spliced into the command string. The fix is not exotic: Python's subprocess module has supported shell=False with an argument list since it replaced os.system as the recommended API in Python 2.4, and that single flag is the difference between a shell parsing metacharacters and a program receiving one literal string. This post walks through why the shell is the actual vulnerability, what OWASP's own mitigation guidance says, and the concrete Python patterns — argument lists, shlex.quote(), and allow-listing — that make a codebase safe by default rather than safe by convention.
Why does shell=True turn user input into code?
Because shell=True tells Python to hand your string to a real shell interpreter, not to execute it directly — and shells treat characters like ;, &&, |, and backticks as syntax, not data. OWASP's command injection page describes exactly this class of attack: an attacker appends a shell metacharacter to expected input so the shell runs a second, attacker-chosen command after (or instead of) the intended one, as in the classic ;rm -rf / suffix. In Python, subprocess.run(f"ping {host}", shell=True) is functionally identical to that PHP example — if host comes from a request parameter and equals 8.8.8.8; cat /etc/passwd, the shell executes both commands in sequence. os.system() has no shell=False option at all; it always shells out, which is why the Python documentation has long recommended subprocess for anything touching external input. Environment manipulation compounds the risk: OWASP also notes that altering variables like $PATH can redirect a program to an attacker-controlled binary of the same name, an issue that exists independently of shell metacharacter filtering.
Does avoiding the shell actually close the vulnerability class?
Yes — this is OWASP's own evidence for why architecture beats filtering. The command injection page contrasts shell-based execution with Java's Runtime.exec(), which does not invoke a shell: metacharacters like |, &&, and || are passed to the target program as literal, usually-invalid arguments instead of being interpreted as control syntax. Python's subprocess module works the same way once you drop shell=True: subprocess.run(["ping", "-c", "1", host], shell=False) passes host as a single argv element to the ping binary's exec() call, with no shell in the middle to parse it. A value like 8.8.8.8; cat /etc/passwd becomes one (invalid) hostname argument, not two commands. This is why shell=False with a list — not shell=True with a sanitizer — is the pattern Python's own subprocess documentation has recommended for years: eliminating the interpreter that understands metacharacters removes the entire attack surface, rather than trying to enumerate every dangerous character an attacker might use.
What does OWASP recommend when you can't avoid a shell?
OWASP's mitigation guidance, in the order the page presents it, is: prefer a language or library API that doesn't call a shell at all; if that's not possible, sanitize input by stripping or rejecting known-dangerous shell metacharacters, explicitly naming ;, &&, |, and || as ones to remove; and, framed as the more durable of the two, apply a positive security model — an allow-list of legal characters — on the reasoning that it's easier to define what's legal than to enumerate everything illegal. In Python the sanitize step maps directly onto shlex.quote() (the standard-library successor to the older pipes.quote), which wraps a string in single quotes and escapes any embedded single quote so a shell treats it as one literal token even under shell=True. It is a legitimate second line of defense, not a first choice — shlex.quote("8.8.8.8; cat /etc/passwd") neutralizes the semicolon, but every call site that builds a command string still has to remember to apply it, and one missed call site anywhere in the codebase reopens the vulnerability. Positive allow-listing is stronger for structured input: if a parameter should only ever be a hostname or a numeric ID, validating it against a regex before it reaches any subprocess call blocks injection regardless of which execution API is used downstream.
Why do f-strings and string concatenation make this worse in practice?
Because building a shell command with an f-string or + concatenation puts the injection point directly in the source line that calls subprocess, which is exactly the pattern that's easy to introduce during a refactor and easy to miss in review. subprocess.run(f"convert {filename} out.png", shell=True) reads like ordinary string formatting, not a security-sensitive operation, so it doesn't get the scrutiny a raw eval() call would. The list-argument form forces the opposite discipline: subprocess.run(["convert", filename, "out.png"]) keeps each value as a distinct argv element with no string assembly step at all, so there's no shell syntax for an attacker's input to hide inside. This is also why taint-tracking static analysis catches this bug class more reliably than a text search for "subprocess" — a scanner has to follow filename back to its source (a request parameter, a CLI arg, an uploaded file's name) and confirm it reaches the sink unsanitized, because the mere presence of subprocess.run() in a file says nothing about whether that specific call is exploitable.
How Safeguard helps
Safeguard's first-party SAST engine performs exactly this source-to-sink dataflow tracing for Python (one of the phase-one supported languages, alongside JavaScript/TypeScript and Java), following untrusted input — a request parameter, a CLI argument, a file read — across functions and files to dangerous sinks, including command-exec sinks like os.system() and subprocess calls made with shell=True. Rather than flagging every subprocess import in a repository, each finding carries the full dataflow trace showing the exact hops from source to sink, plus a CWE/OWASP mapping, severity, and code location, so a reviewer can see why a specific call is reachable by an attacker rather than re-deriving it by hand. Running safeguard appsec sast --dir ./src in CI surfaces these command-injection sinks alongside the rest of your findings, tenant-scoped and queryable through the same API and views as your SCA and secrets results — so the shell=True call introduced in a routine refactor gets caught before it merges, not after an incident.