When Input Becomes a Shell Command#

Command injection is among the most severe web flaws because a successful exploit does not just read a database, it runs commands on the operating system. If untrusted input reaches an OS command, an attacker can read files, exfiltrate secrets, open a reverse shell, or destroy the host. It happens the moment an application hands user data to a shell, directly or through a library that shells out on your behalf.

Do not confuse it with SQL injection, which runs inside the database, or with code injection, which runs in the application's own language. Command injection runs in the operating system shell, with whatever privileges the application process holds.

How It Happens#

The root cause is building a shell command string by concatenating user input. Consider a "ping this host" feature:

import subprocess

# VULNERABLE: user_input is interpolated into a shell command line
def ping(user_input: str):
    return subprocess.run(f"ping -c 1 {user_input}",
                          shell=True, capture_output=True, text=True).stdout

With shell=True, Python hands the whole string to /bin/sh, which interprets shell metacharacters. A normal value like example.com works. But the shell treats several characters as command separators:

  • ; runs a second command unconditionally
  • && and || run a second command conditionally
  • | pipes into another command
  • ` and $( ) substitute the output of a command
  • & runs in the background

So an input such as example.com; id makes the shell run ping, then run id as a separate command. You can watch the mechanism safely on your own machine with a harmless second command:

import subprocess

def demo(arg: str):
    # lab only: echo stands in for any injected command
    return subprocess.run(f"echo pinging {arg}", shell=True,
                          capture_output=True, text=True).stdout

print(demo("example.com"))            # pinging example.com
print(demo("example.com; echo INJECTED"))
# pinging example.com
# INJECTED

The second call runs two commands: the intended echo and the attacker's echo INJECTED. Replace that with any real command and the impact is obvious. The danger multiplies where the code runs with elevated rights, such as a cron job or a CI pipeline, because the injected command inherits those credentials.

The Fix: Never Build Command Strings#

The rule mirrors parameterized SQL: do not construct command text from user data. Pass the program and its arguments as a list, with shell=False (the default), so no shell is involved and metacharacters are treated as literal data:

import subprocess

def ping_safe(host: str):
    # No shell. Each list item is one argument passed directly to the program.
    return subprocess.run(["ping", "-c", "1", host],
                          capture_output=True, text=True).stdout

print(ping_safe("example.com; echo INJECTED"))
# ping: example.com; echo INJECTED: Name or service not known

The whole string example.com; echo INJECTED is handed to ping as a single hostname argument. ping fails to resolve it, but nothing else runs, because there is no shell to interpret the semicolon. Python executes the binary directly via execve, and the tokens are exactly what you put in the list.

The same principle applies across languages: in Node use execFile/spawn with an argument array rather than exec with a string; in Ruby use system(*args) (splat) rather than system("cmd " + arg); in Java pass a String[] to ProcessBuilder rather than a single command line; in Go use exec.Command(name, args...). Avoid os.system, backticks, and any "run this whole line" API when user data is involved.

If you truly need shell features (globbing, pipelines), the safe approach is to still avoid interpolation: build the argument list yourself, or use shlex.quote to escape a value before it enters a shell string, understanding that quoting is more fragile than avoiding the shell entirely.

import shlex
# If a shell is unavoidable, quote untrusted values (still prefer the list form)
safe_fragment = shlex.quote("example.com; rm -rf /")
print(safe_fragment)     # 'example.com; rm -rf /'  -> a single quoted literal

Argument Injection: The Subtle Cousin#

Using an argument list closes the classic hole, but there is a subtler variant called argument injection. Even with no shell, if user input becomes an argument to a program, it can supply an option the program understands rather than the value you intended. A value beginning with - or -- may be parsed as a flag. For example, passing a user-controlled string as a filename to a tool that treats a leading dash as an option can change the tool's behaviour, and some archive utilities have options that execute commands. The defences are specific:

  • Where a program supports it, use -- to mark the end of options, so everything after is treated as positional arguments: ["grep", "--", pattern, file].
  • Validate that inputs which should be filenames or hostnames actually look like them, rejecting values that start with -.
  • Prefer language libraries over shelling out at all: read files with the standard library, make HTTP requests with an HTTP client, and resolve DNS with a resolver library, rather than invoking cat, curl, or nslookup.

Blind Command Injection#

Often the command's output is never shown to the user, so an attacker cannot see the result directly. They confirm the flaw the same way blind SQL injection is confirmed: with a time delay or an out-of-band signal. Injecting a sleep 10 and observing whether the response takes ten seconds proves execution without any visible output; triggering an outbound DNS or HTTP request to a server the attacker controls proves it and exfiltrates data. This is why restricting egress and monitoring process execution matter even when a feature "does not return command output", the absence of visible output is not the absence of a vulnerability.

Injection Surfaces Are Everywhere#

The vulnerable pattern hides wherever a program is invoked, not just in obvious "run a command" features:

  • Image and document processing that passes filenames to tools like convert, ffmpeg, or pdftotext.
  • Archive handling, where a crafted filename inside a zip or tar can carry option-like strings.
  • DNS lookups, git operations, and calls to system utilities for tasks a pure library could do.
  • Legacy os.system() and backtick calls scattered through older code.

A useful habit: any time you find a filename, URL, or free-text field flowing toward a process launch, treat it as a candidate and verify it uses the argument-list form.

Defence in Depth#

Assume, despite your care, that one injection slips through a dependency you do not control. Layer defences so a successful injection has nowhere to go:

  • Least privilege. The application should not run as root. Then an injected command cannot read /etc/shadow or install services. This single control turns many "critical" injections into "annoying."
  • Allowlist literal commands. If a value must select which command runs, pick it from a fixed dictionary of allowed options rather than passing a user string.
  • Containerise with a read-only root filesystem and a non-root user, so writing a payload to disk fails.
  • Restrict egress at the network layer (firewalls), so a reverse shell's outbound connection is dropped even if the command runs.
  • Log and monitor process execution, and scan the codebase for the danger signs: shell=True, os.system, backticks, subprocess with an f-string, eval, and exec.

Checklist#

  • Every external command uses an argument list with shell=False, never an interpolated string.
  • No user input reaches os.system, backticks, or shell=True without an allowlist.
  • The service runs as a non-root, least-privilege account.
  • Egress is restricted so outbound reverse shells fail.
  • A code-review grep for shell=True and os.system returns nothing unexpected.
  • Tested on a lab (DVWA's command injection page, or your own sandbox), never a live third-party system.