FlawOpen

flawopen.com/command-injection/Python

● CWE-78 · Critical
FlawOpen Security Research

OS Command Injection in Python (subprocess, os.system)

os.system(), an f-string passed to subprocess with shell=True, or a user value that starts with a dash lets a request run extra commands on your server with the application's privileges (CWE-78). The safe subprocess call, why an argument list fixes the shell problem but not option injection, and the CI rules that catch both.

💡 Plain English Explainer (ELI5)

A self-service print shop asks for a page count, but its oldest machine reads the whole handwritten job line as a sequence of operating instructions. A semicolon can turn the requested page count into a second job. A newer machine accepts separate fields, yet the printer still treats a leading dash as a special option. Python has both boundaries: os.system() and subprocess.run(..., shell=True) invoke a shell; an argument list avoids shell parsing, but the called program still parses its own options.

Core Concepts & Subsystem Terms

os.system() / os.popen()
os.system() and os.popen() pass a command string to the platform shell (for example, /bin/sh -c on Unix). Shell metacharacters such as ;, |, &&, $( ) and backticks are syntax there. Neither API provides a shell=False argument-list form; use them only for a fixed command with no dynamic values.
subprocess.run(..., shell=True)
Same problem as os.system: with shell=True the first argument is parsed by the shell. An f-string or % in that argument puts user text where the shell reads operators.
Argument list with shell=False
With shell=False (the default) and a list such as ["ping", "-c", "1", host], Python calls execve directly. Each list item is one argument; ; or $( ) inside host are just characters.
Argument injection and Git option boundaries
Separate argv entries prevent shell parsing, but the target program still parses its own options. For an untrusted Git revision, use git rev-parse --verify --end-of-options "$revision": this ends option parsing. -- has command-specific operand or path semantics; in git log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.
shlex.quote()
shlex.quote() is intended for Unix/POSIX shells and is not a portable substitute for an argument list on Windows or other shell implementations. Avoid shell parsing where possible; with shell=False, pass a list and validate the target program's arguments separately.

Framework mappings

Step-by-Step Attack Flow

Step 1

Request value reaches the helper

A diagnostics page passes host to ping(), or an upload handler passes a filename to make_thumbnail(); the code calls os.system() or subprocess.run().

Step 2

An f-string builds a command line

os.system() passes its string to the platform shell, and subprocess.run(..., shell=True) does the same. Shell syntax can split one request into another command; shell behavior differs by operating system.

Step 3

The shell splits it into two commands

With shell=False and an argument list, ; is ordinary data, but a Git ref beginning with --output can still be parsed as an option. Python’s list prevents shell parsing; it does not validate paths or the target program’s arguments.

Step 4

The attacker runs code on the server

The process can use the app account’s files, credentials, and network access. Pass a list with shell=False, validate host and filename values, keep paths within the upload directory, and put --end-of-options before Git revisions.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
# tools.py: each helper below lets request text reach a command line
import os
import subprocess


# os.system passes the command string to the platform shell
def ping(host):
    return os.system(f"ping -c 1 {host}")


# shell=True: the f-string is parsed by the shell
def make_thumbnail(filename):
    subprocess.run(f"convert uploads/{filename} -resize 200x200 thumbs/{filename}", shell=True, check=True)


# no shell, but a ref starting with - is read as a Git option
def commit_log(ref):
    return subprocess.run(["git", "log", ref], capture_output=True, text=True, check=True).stdout
HARDENED SECURE PATCH
# tools.py: argument lists, strict patterns and an end-of-options marker
import re
import subprocess

HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.-]{0,252}")
IMAGE_RE = re.compile(r"[A-Za-z0-9_-]{1,64}\.(?:png|jpe?g)")


# validated host passed as its own argument, no shell
def ping(host):
    if not HOST_RE.fullmatch(host):
        raise ValueError("invalid host name")
    return subprocess.run(["ping", "-c", "1", host], check=False).returncode


# each list item is one argument; ; and $( ) are plain characters
def make_thumbnail(filename):
    if not IMAGE_RE.fullmatch(filename):
        raise ValueError("invalid image name")
    subprocess.run(["convert", f"uploads/{filename}", "-resize", "200x200", f"thumbs/{filename}"], check=True)


# --end-of-options makes Git treat ref as a revision, never an option
def commit_log(ref):
    result = subprocess.run(["git", "log", "--end-of-options", ref, "--"], capture_output=True, text=True, check=True)
    return result.stdout

Engineering & System Hardening Checklist

Sources