CWE-77: Command Injection - PHP
Overview
PHP has several functions that hand a string straight to the shell - system(), exec(), shell_exec(), passthru(), popen(), and the backtick operator all invoke /bin/sh (or cmd.exe on Windows) and are vulnerable whenever untrusted data reaches the command string.
Primary defense: avoid shell execution functions entirely - use a PHP built-in function or extension for the task (sockets, curl, ZipArchive, GD/Imagick) instead of shelling out. When a system command is genuinely unavoidable, use proc_open() with an explicit argument array (not a string), which never invokes a shell, plus strict allowlist validation.
Common Vulnerable Patterns
All of PHP's shell-execution functions share the same weakness - untrusted data concatenated into the command string.
system(), exec(), shell_exec(), passthru(), popen() and backticks
<?php
// VULNERABLE - system(), exec(), shell_exec(), passthru(), popen(), and backticks
// are all shell-invoking and equally exploitable
$ip = $_GET['ip'];
system('ping -c 4 ' . $ip);
// Attack: ip = "8.8.8.8; cat /etc/passwd"
$domain = $_REQUEST['domain'];
$result = shell_exec('nslookup ' . $domain);
// Attack: domain = "example.com || whoami"
Why this is vulnerable: every one of these functions hands its string to a shell - /bin/sh -c on Unix, cmd.exe /c on Windows - so ;, &&, ||, |, backticks and $() in $ip or $domain are operators rather than characters. There is no "safe one" among them to switch to; they differ only in what they return (shell_exec() gives stdout, system() prints it, passthru() passes binary output straight through), not in whether a shell parses the argument. proc_open() belongs to the same list when its first argument is a string.
Neither escaping function is the answer, and they fail differently. escapeshellcmd() neutralises shell metacharacters across a whole command string, so the payloads above stop working and the finding appears fixed - but it leaves spaces and quoting alone, so the input can still split into several arguments. escapeshellarg() is the better of the two and is covered below as a fallback: it quotes the value so it arrives as exactly one shell argument, which is all PHP's documentation promises. What neither does is stop the program from reading that argument as an option, because a quoted -oProxyCommand=... is still one argument beginning with a hyphen when ssh parses its own command line. That is a decision made past the shell, so no amount of shell escaping reaches it - it needs validation of the value, -- before the positional arguments where the command supports it, or rejecting anything that starts with -.
Secure Patterns
Use PHP Native Functions (Primary Defense)
<?php
// Instead of: system('ping ' . $host)
// \A and \z, not ^ and $ - see "Validate Input" below. The first character
// cannot be '-', so the value can never be read as a flag by a later caller.
const HOSTNAME_RE = '/\A[a-zA-Z0-9][a-zA-Z0-9.-]*\z/';
function isHostReachable($hostname) {
if (!preg_match(HOSTNAME_RE, $hostname)) {
throw new InvalidArgumentException('Invalid hostname');
}
$socket = @fsockopen($hostname, 80, $errno, $errstr, 5);
if ($socket) {
fclose($socket);
return true;
}
return false;
}
// Instead of: shell_exec('curl ' . $url) -> curl_init() / curl_exec()
// Instead of: exec('tar -czf archive.tar.gz ' . $files) -> ZipArchive
// Instead of: exec('convert ' . $image . ' thumbnail.jpg') -> GD or Imagick extension functions
Why this works: fsockopen(), curl_exec(), ZipArchive, and GD/Imagick talk to sockets, libcurl, zlib, and libjpeg directly through PHP's own extensions - there is no shell in the path, so shell metacharacters in the input have no special meaning.
The deeper reason to prefer this over escaping is that it removes the weakness rather than managing it. Escaping has to be correct at every call site, forever: one refactor that reintroduces concatenation, one value that skips escapeshellarg(), one context where quoting rules differ, and the vulnerability is back. A native API has no shell to inject into, so there is no rule for a future maintainer to get wrong.
It also avoids inheriting vulnerabilities from the CLI tool itself. ImageTragick (CVE-2016-3714) let a crafted image reach ImageMagick's delegate handling and execute shell commands - the injection happened inside the tool, past any escaping the calling application did. Correct escapeshellarg() use would not have prevented it; not invoking the tool through a shell would.
Use proc_open() with an Argument Array (When a Command Is Unavoidable)
<?php
function executeCommandSafely($command, array $args) {
// Allowlist of permitted commands, resolved to absolute paths
$allowedCommands = [
'ping' => '/bin/ping',
'nslookup' => '/usr/bin/nslookup',
];
if (!isset($allowedCommands[$command])) {
throw new InvalidArgumentException('Command not allowed');
}
$cmd = array_merge([$allowedCommands[$command]], $args);
// stdout and stderr both go to temp files, not pipes. A pipe nobody reads
// fills up and blocks the child - see "Why this works" below.
$out = tmpfile();
$err = tmpfile();
$descriptors = [0 => ['pipe', 'r'], 1 => $out, 2 => $err];
// Array form invokes the executable directly - no shell involved
$process = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Failed to start process');
}
fclose($pipes[0]); // the child sees EOF on stdin rather than waiting
$status = proc_close($process);
rewind($out);
$output = stream_get_contents($out);
fclose($out);
if ($status !== 0) {
rewind($err);
$message = stream_get_contents($err);
fclose($err);
throw new RuntimeException("$command exited $status: $message");
}
fclose($err);
return $output;
}
// Usage: FILTER_VALIDATE_IP rejects "8.8.8.8\n" as well as "8.8.8.8; whoami",
// and an IP address can never begin with '-', so there is no flag to inject.
$ip = filter_var($_GET['ip'], FILTER_VALIDATE_IP) ?: die('Invalid IP');
echo executeCommandSafely('ping', ['-c', '4', $ip]);
Why this works: proc_open() given an array (not a string) invokes the executable directly via fork()/exec(), exactly like ProcessBuilder in Java or ArgumentList in .NET - there is no shell present to interpret ;, &, |, or backticks in an argument, so they are passed through as literal characters. The command allowlist, resolved to absolute paths, prevents an attacker from substituting a different executable via PATH manipulation.
The stream handles are the part that is easy to get wrong. The obvious spelling declares 1 => ['pipe', 'w'] and 2 => ['pipe', 'w'], reads stdout, and closes - which deadlocks the moment the child writes more to stderr than the pipe buffer holds, because nothing ever drains that pipe and the child blocks in write() while stream_get_contents($pipes[1]) waits for a stdout EOF that will never come. Measured on PHP 8.5.8: a child writing 300 KB to stderr under exactly that shape hung until an external 60-second timeout killed it and returned 0 bytes of stdout; sending stderr elsewhere and changing nothing else, the same code returned the correct output in 0.1 s. PHP's proc_close() documentation names the hazard - "the child process may not be able to exit while the pipes are open" - but its answer, closing the pipes itself, is no help here, because the block happens in the stream_get_contents() above it and proc_close() is never reached. Redirecting both streams to temp files, as above, means neither can fill; the alternative is stream_set_blocking() on both pipes and a stream_select() loop that drains them together, which is correct and considerably more code.
proc_open() has no timeout of its own. proc_close() blocks until the child exits, so a command that hangs hangs the request. Where the command can be slow or attacker-influenced, use proc_get_status() in a bounded loop and proc_terminate() when the budget runs out, or reach for Symfony's Process (below), which has setTimeout() built in.
escapeshellarg() Is a Fallback, Not the Primary Defense
If a shell function truly cannot be replaced, wrap every argument individually with escapeshellarg() - never rely on escapeshellcmd() alone, which escapes metacharacters across the whole command string but leaves the input free to split into several arguments. What escapeshellarg() buys is narrow: it makes the value a single shell argument, which stops word splitting and metacharacter interpretation, and that is the whole of what PHP's documentation promises. It does not stop the invoked command from treating that argument as an option, so -oProxyCommand=... reaches ssh intact and -o reaches curl intact. Validate the input first (filter_var($ip, FILTER_VALIDATE_IP), or a strict regex), reject values beginning with -, and pass -- before the positional arguments where the command supports it. Escaping is a defense-in-depth layer on top of validation, not a substitute for it.
Validate Input (Defense in Depth)
Even with shell execution eliminated, validate before use: an anchored allowlist pattern for hostnames, filter_var($input, FILTER_VALIDATE_IP) for IP addresses, and explicit rejection of .., /, \ in filenames to block path traversal. Fail closed - throw an exception rather than attempting to strip or "clean" suspicious characters.
Anchor with \A and \z, not ^ and $. PCRE's $ matches immediately before a trailing newline as well as at the end of the subject, so preg_match('/^[a-zA-Z0-9.-]+$/', "evil.com\n") returns 1 - measured on PHP 8.5.8 - and an allowlist written to be strict admits a value carrying a control character. Both /\A[a-zA-Z0-9.-]+\z/ and the D (PCRE_DOLLAR_ENDONLY) modifier return 0 for the same input; prefer \z, because it says what it does at the point of use rather than in a modifier at the far end of the pattern. PHP's own filter_var() filters do not have this problem - FILTER_VALIDATE_IP rejects "8.8.8.8\n" - so prefer a filter over a hand-written pattern wherever one exists.
Reject a leading hyphen. [a-zA-Z0-9.-]+ includes -, so -debug passes it, and a value starting with a hyphen is read as an option by whatever program eventually receives it. Neither escapeshellarg() nor proc_open()'s array form touches that: both deliver the argument faithfully, hyphen and all. Anchor the first character to something that cannot introduce a flag (\A[a-zA-Z0-9][a-zA-Z0-9.-]*\z), and pass -- before the positional arguments where the command supports it. That is CWE-88, and it is the half of the problem that survives the shell fix.
Framework-Specific Guidance
Laravel
Use the Symfony Process component (bundled with Laravel) instead of shell_exec(), and validate with Laravel's own validator:
<?php
$request->validate(['ip' => 'required|ip']);
$process = new \Symfony\Component\Process\Process(['ping', '-c', '4', $request->input('ip')]);
$process->setTimeout(10);
$process->mustRun();
return response($process->getOutput());
Process takes an argument array the same way proc_open() does, so it never invokes a shell.
WordPress
Sanitize with WordPress's own helpers, validate the format, and escape as a last resort if a shell call cannot be avoided:
<?php
add_action('admin_post_run_diagnostic', function () {
// admin_post_ fires for any logged-in user, including a subscriber. The
// nonce proves the request came from your form, not that the sender is
// allowed to run a subprocess - both checks are needed.
if (!current_user_can('manage_options')) {
wp_die('Insufficient permissions', '', ['response' => 403]);
}
check_admin_referer('diagnostic_nonce');
$host = sanitize_text_field($_POST['host'] ?? '');
if (!filter_var($host, FILTER_VALIDATE_IP)) {
wp_die('Invalid IP address');
}
$output = [];
exec('ping -c 4 ' . escapeshellarg($host), $output);
echo esc_html(implode("\n", $output));
});
Considerations
The first question is whether a subprocess is needed at all. Most findings of this kind are a shell call standing in for a library the platform already ships - fetching a URL, unpacking an archive, resizing an image. Replacing the call removes the weakness rather than containing it, and usually removes error handling and portability problems with it. That is a rewrite, so weigh it against hardening the existing call; but a hardened subprocess still runs another program with your privileges, and the library does not.
The timeouts in the examples are placeholders for a decision you have to make. A subprocess with no bound can hang a request thread indefinitely, so one is needed - but the right value comes from what the command legitimately does. Too short and normal work fails under load; too long and an attacker who can influence the input has a cheap way to exhaust your workers. Bound the output as well as the time: a command that returns unbounded data to an in-memory buffer is a denial of service whether or not the arguments were validated.
An allowlist is only as good as its most permissive entry. Restricting which command may run is worth doing, but a permitted command that itself takes a path, a URL, or a format string moves the problem one level down rather than solving it. Prefer allowing a fixed set of complete invocations over allowing a program and validating its arguments separately.
Testing
- Normal input:
executeCommandSafely('ping', ['-c', '4', '8.8.8.8'])returns ping output rather than throwing. A pattern that refuses everything passes every assertion below, so the accept is what distinguishes a fix from a broken validator. - Boundary input: an empty string, a 300-character hostname, and a filename containing
..are all rejected. - Anchoring:
"evil.com\n"is rejected.preg_match('/^...$/')accepts it, so this assertion is what separates\zfrom the pattern it replaced. - Argument injection:
'-debug'is rejected before it reaches the command. It contains no shell metacharacter, so none of the payloads below covers it. - Output volume: a command writing more than a pipe buffer's worth of output to stderr still returns. Test with a chatty command - the pipe version deadlocks only above the OS buffer, and passes with a one-line child.
- Malicious input:
8.8.8.8; cat /etc/passwd,`whoami`,$(cat /etc/shadow), andexample.com || whoamiare all rejected or treated as a single literal argument, never executed.
Common Pitfalls
proc_open()given a string instead of an array: The array-form protection applies only when an array is actually passed -proc_open('ping -c 4 ' . $ip, ...)with a pre-concatenated string still invokes/bin/sh -con POSIX systems, with the same injection risk assystem()orshell_exec().- Swapping between
exec(),shell_exec(),system(), andpassthru(): All four invoke a shell and share an identical injection surface. Changing which one is used, without switching toproc_open()with an argument array, changes nothing about the vulnerability. - Relying on framework input sanitizers to neutralize shell metacharacters: Helpers like Laravel's
strip_tags()-based sanitization or WordPress'ssanitize_text_field()target HTML/XSS cleanup - they don't strip or escape;,&, backticks, or$(), so a shell-injection payload passes through unaffected even after "sanitization."