flawopen.com/command-injection/Php
OS Command Injection in PHP (proc_open)
shell_exec(), system() and backticks hand a string to /bin/sh, escapeshellcmd() still lets extra arguments through, and mail()'s fifth parameter reaches the sendmail command line, so user input in any of them can run code on your PHP server (CWE-78). How each one fails and why proc_open() with an array is the fix.
A post office uses separate boxes for the recipient, sender, and message. If the sender’s name is appended to a mailer’s command options, text meant to identify a person can become an instruction to the mailer. PHP has the same split: shell_exec() sends a command string through a shell, escapeshellcmd() does not turn a multiword string into one argument, and mail() accepts a fifth parameter for sendmail options. Keep user data in validated fields and pass process arguments as an array with proc_open().
Core Concepts & Subsystem Terms
shell_exec(), system(), exec(), passthru() and backticks- All of these pass one string to
/bin/sh -c, which parses;,|,&&and$( ). Concatenating request data into that string lets it start extra commands. escapeshellcmd()- Escapes shell metacharacters in a whole command string but leaves spaces and leading dashes alone, so user text can still add arguments and options. It does not make a concatenated command safe.
proc_open() with an array- Since PHP 7.4, passing an array as the command runs the program directly with each element as one argument, with no shell in between. Combine it with fixed program names.
mail() fifth parameter (CVE-2016-10033)- The
additional_paramsargument ofmail()is appended to the sendmail command line. InCVE-2016-10033, PHPMailer passed the sender address there, and a crafted address added sendmail options such as-Xthat write a log file to a chosen path. Argument injection and Git option boundaries- Separate argv entries prevent shell parsing, but the target program still parses its own options. For an untrusted Git revision, use
git rev-parse --verify --end-of-options "$revision": this ends option parsing.--has command-specific operand or path semantics; ingit log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-78
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Request value reaches the helper
A controller reads $_GET['host'], $_GET['ref'], or a form-provided sender address and passes the string to shell_exec(), escapeshellcmd(), or mail().
The value becomes part of a command line
Concatenating host into shell_exec() invokes a shell; escapeshellcmd() protects shell metacharacters but leaves spaces that separate arguments; appending a caller-controlled sender to mail()’s fifth parameter reaches sendmail options.
The shell or the program interprets it
Shell separators can start another command, Git can parse added words as options, or sendmail can treat the appended text as command-line flags. escapeshellcmd() is not an argument-array API and does not make a value safe for every downstream parser.
The attacker runs code on the server
Commands run with the PHP-FPM account’s access to application files, .env credentials, and network services. Use proc_open() with an argument array, validate each field, keep the sender fixed, use --end-of-options where supported, and impose a timeout.
Source Code: Flaw vs. Secure Implementation
<?php
// tools.php: each function lets request data change what runs
declare(strict_types=1);
// shell_exec() runs the string through sh -c, ; and all
function ping(string $host): string
{
return shell_exec('ping -c 1 ' . $host) ?? '';
}
// escapeshellcmd() keeps spaces, so ref can add Git options
function commitLog(string $ref): string
{
return shell_exec(escapeshellcmd('git log ' . $ref)) ?? '';
}
// the sender address is appended to the sendmail options
function sendReceipt(string $to, string $from): bool
{
return mail($to, 'Receipt', 'Thanks for your order.', [], '-f' . $from);
}
<?php
// tools.php: argument arrays, validation, a fixed sender and a time limit
declare(strict_types=1);
const RECEIPT_SENDER = 'receipts@example.com';
// array command: no shell, one argument per element, 5-second limit
function run(array $command): string
{
$pipes = [];
$process = proc_open(
['timeout', '5', ...$command],
[1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']],
$pipes
);
if ($process === false) {
throw new RuntimeException('could not start process');
}
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
proc_close($process);
return $output === false ? '' : $output;
}
// ping runs directly; host is one validated argument
function ping(string $host): string
{
if (preg_match('/^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/D', $host) !== 1) {
throw new InvalidArgumentException('invalid host name');
}
return run(['ping', '-c', '1', $host]);
}
// --end-of-options makes Git treat ref as a revision
function commitLog(string $ref): string
{
if (preg_match('~^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$~D', $ref) !== 1) {
throw new InvalidArgumentException('invalid ref');
}
return run(['git', 'log', '--end-of-options', $ref, '--']);
}
// only a validated recipient comes from the request
function sendReceipt(string $to): bool
{
if (filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException('invalid address');
}
return mail($to, 'Receipt', 'Thanks for your order.', ['From' => RECEIPT_SENDER], '-f' . RECEIPT_SENDER);
}
Engineering & System Hardening Checklist
- ✓Enable Psalm taint analysis (TaintedShell) or an equivalent rule and ban
shell_exec,system,exec,passthru,popenand backticks with non-constant arguments. - ✓Start processes with
proc_open()and an array command with a fixed program name, wrapped intimeoutor another time limit; never callescapeshellcmd()on a concatenated command. - ✓Never pass request data to the fifth parameter of
mail(); use a fixed sender from configuration and validate recipients withFILTER_VALIDATE_EMAIL. - ✓Validate every argument with a regex anchored by
^and$plus theDmodifier (so$does not accept a trailing newline) that requires a letter or digit first. - ✓Reject option-like values by default. For an untrusted Git revision, call
git rev-parse --verify --end-of-options "$revision"; use--only with the operand or path meaning documented by that exact command.
Sources
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- PHP Manual — proc_open (array command since 7.4)
- PHP Manual — escapeshellcmd
- NVD — CVE-2016-10033 (PHPMailer mail() sender injection)
- Psalm — TaintedShell
- OWASP OS Command Injection Defense Cheat Sheet
- POSIX Utility Syntax Guidelines
- Git rev-parse — --end-of-options
- Git — Command-Line Interface Conventions
- Git — git log