When Input Becomes a Shell Command

Command injection is the severity king of web flaws: untrusted input that reaches an operating-system command lets an attacker run arbitrary commands on the server — read files, exfiltrate secrets, spawn reverse shells, or wipe the box. It happens the moment an application hands user data to a shell, directly or through a library that shells out.

The Classic Vector

The canonical bad pattern is building a shell string by concatenation:

# VULNERABLE — user-supplied host flows into the shell
import subprocess
out = subprocess.run(f"ping -c 1 {user_input}", shell=True, capture_output=True)

Enter 127.0.0.1; cat /etc/passwd or 127.0.0.1 && evil.sh and the shell treats everything after the separator as new work — the semicolon, &&, | (pipe), and backticks all terminate one command and start another. On Windows the syntax differs (&, |), which is why the flaw is present on every platform. Even "harmless" interpolation of a filename can become rm -rf when the filename contains shell metacharacters. The impact is multiplicative: an injection in a cron job or a CI pipeline runs with those jobs credentials, so a tiny shell-out in an otherwise benign utility becomes a read of every credential that utility touches.

The Rule: Separate Code from Data

The shell safety rule parallels the SQL injection rule: never construct command text from user data; pass arguments as a list so no interpolation happens:

# SAFE — no shell involved
subprocess.run(["ping", "-c", "1", user_input], check=True)

With shell=False (default) and a list of arguments, Python executes the binary directly (via execve) with the tokens exactly as supplied — metacharacters are data, never syntax. Never reach for shell=True unless you truly need shell features, and even then prefer shlex to parse untrusted text before it enters. The same list-based rule applies in Node (execFile vs exec), Ruby (system(*args)), and Java (never Runtime.exec on a string with user data).

Injection Surfaces Everywhere

The injection surfaces are everywhere a system call is made: image/PDF processing that hands filenames to convert or pdftotext, archive tools fed a crafted zip (a tar filename like --checkpoint-action=exec=... is a real weaponized vector), Git hooks, DNS lookups, cron entries, and legacy os.system() calls. The defense is the same everywhere: arguments-as-list, an allowlist for anything that must be a literal command (pick from a fixed map of allowed values), and least privilege on the service account (the app should not run as root in the first place — then a successful injection has nowhere to escalate).

Defense in Depth Is Non-Negotiable

Assume one injection slips through a dependency you cannot see. Containerize with a read-only root filesystem and a least-privilege user; egress-restrict the workload so a reverse-shell's outbound connection fails at the network layer (the firewalls lesson); log command execution; and scan for the classic shell=True, os.system, eval, and $ interpolation patterns in code review. Command injection is the one web flaw whose successful exploit means the attacker is now in your operating system, so the mitigation is layered architecture, not a single escaping call.