In short: The safest way to prevent command injection in PHP is to call no shell at all: PHP functions or libraries handle many tasks directly. Where an external program is necessary, since PHP 7.4 you pass the command as an array to proc_open(), and PHP then starts the program without a shell. You check values against an allowlist, end options with -- and run the process with minimal rights.
Why a System Call Has So Much Power
Image editing, PDF generation, archiving, virus scanning, querying a DNS record: for such tasks, PHP applications like to call external programs. Technically that is convenient; one line with exec() or shell_exec() is enough. From a security standpoint, it is one of the most dangerous spots in the code.
If a command is assembled as text and executed by a shell, the shell decides what is command and what is data. If a value from outside gets in unchecked, it can change the meaning of the command. In the worst case, the consequence is the same as with PHP2Shell: execution of arbitrary commands on the server with the rights of the PHP process.
The difference: you fix PHP2Shell with a PHP update. A command injection in your own code persists after every update. In addition, such spots are often in helper scripts that were meant only for admins, for example for maintenance, diagnostics or imports, and later remain reachable without protection.
Where Command Injection in PHP Arises
Every spot where PHP starts a shell or a program is at risk. The functions for this are:
| Function | Uses a shell? | Note |
|---|---|---|
exec(), system(), passthru(), shell_exec() |
yes | command as text, the shell interprets it |
| Backtick operator | yes | equivalent to shell_exec() |
popen() |
yes | command as text |
proc_open() with a string |
yes | command as text |
proc_open() with an array |
no | since PHP 7.4, arguments are passed directly |
mail() with fifth parameter |
indirectly | additional parameters to the mail program |
A typical insecure pattern looks like this. The file name comes from the request and ends up directly in the command line:
<?php
// INSECURE: value from the request becomes part of a shell command
$datei = $_POST['datei'];
exec('convert uploads/' . $datei . ' -resize 800x800 vorschau/' . $datei);
First Choice: No Shell at All
The most effective protection is to replace the system call. OWASP expressly names this as the first line of defense. For many tasks, PHP has its own functions or extensions:
- Directories and files:
mkdir(),copy(),rename(),unlink() - Archives:
ZipArchiveorPharData - Images: GD or Imagick as a PHP extension
- Network queries:
dns_get_record(),gethostbyname(), cURL - Checksums:
hash_file()
This removes the question of which characters a shell interprets entirely. In addition, the functions for system commands can then be blocked in the configuration, as the guide Using disable_functions Correctly describes.
When an External Program Is Needed: proc_open with an Array
Since PHP 7.4, proc_open() accepts the command as an array. According to the PHP manual, the process is then started directly, without a detour through the shell, and PHP takes care of the necessary escaping of the arguments. Characters such as semicolon or pipe then no longer have a special meaning.
<?php
declare(strict_types=1);
$name = basename((string) ($_POST['datei'] ?? ''));
if (!preg_match('/^[a-z0-9_-]{1,64}\.(jpg|png)$/', $name)) {
throw new InvalidArgumentException('Ungültiger Dateiname.');
}
$befehl = ['/usr/bin/convert', '/srv/app/uploads/' . $name, '-resize', '800x800',
'/srv/app/vorschau/' . $name];
$prozess = proc_open($befehl, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($prozess)) {
throw new RuntimeException('Programm konnte nicht gestartet werden.');
}
$fehlerausgabe = stream_get_contents($pipes[2]);
$status = proc_close($prozess);
Three things make the example robust: the program is fixed in the code with its full path, the file name is checked against a narrow pattern, and every variable argument begins with an absolute path, so it cannot be read as an option. Where a value stands alone, end the options with -- if the program supports that according to its documentation, or reject values that begin with a hyphen.
For tasks that regularly need external programs, such as image or PDF processing on a larger scale, decoupling is worthwhile: the web application only places a job in a queue, and a separate background service with its own user and its own rights executes it. The PHP process that answers requests from the internet then no longer needs functions for system commands, and they can be blocked for it. External programs should also have time and memory limits so that a single job does not block the server.
A special case is mail(). The fifth parameter passes additional options to the system's mail program. Values from forms, such as a sender address, must never end up there unchecked. A mail library that sends via SMTP and needs no system call is more robust.
escapeshellarg: Using It Correctly and Knowing Its Limits
Where existing code still works with command lines, escapeshellarg() for every single value is the minimum measure. The function puts the value in quotation marks so that the shell treats it as exactly one argument. escapeshellcmd() is unsuitable for user input because it permits additional parameters, as OWASP shows with an example.
Important limits of escapeshellarg():
- Argument injection remains possible: An escaped value that begins with a hyphen is still read as an option by the target program.
- Windows behaves differently: There, according to the manual, the function replaces certain characters such as
%and"with spaces; values therefore arrive altered. - Character set: Depending on the server's locale setting, non-ASCII characters can be lost.
Finding Occurrences in Existing Code
A search for system calls is quick and yields a manageable list:
grep -rnE "\b(exec|system|passthru|shell_exec|popen|proc_open)\s*\(" src/ --include="*.php"
grep -rn '`' src/ --include="*.php" | grep -v '^\s*//'
Every match needs an answer to three questions: can the call be replaced by PHP functions? Do values from outside flow into it? With what rights does the process run? The last question is decisive for the damage in a worst case. A PHP process with its own user and without write rights on the code does less harm, see Least Privilege: Permissions of the Web Server User.
If a PHP-FPM or web server process starts a shell, that is a strong warning sign of a break-in. How to recognize this is described in Warning Sign: The Web Server Starts a Shell.
How does PHP SOS help?
- We search for all system calls in your application, in plugins and in helper scripts.
- Where possible, we replace calls with PHP functions and convert the rest to calls without a shell.
- We block unneeded functions in the PHP configuration and check the rights of the PHP user.
- We test the changes and document which programs the application may still call.
We offer the review of your own software as a security audit and penetration test.
Frequently asked questions
Is escapeshellarg enough against command injection?
It prevents a value from being executed as a further command. It does not prevent the value from being read as an option of the called program if it begins with a hyphen. So also validate values and, where the program supports it, end options with --.
What is the difference between escapeshellarg and escapeshellcmd?
escapeshellarg treats a single value as exactly one argument. escapeshellcmd only escapes metacharacters in an entire command line and permits additional parameters. For user input, escapeshellarg is the right choice, and a call without a shell is better still.
Should I block exec and system via disable_functions?
If your application does not need them, yes. That limits the damage after a break-in. It does not replace secure code, though, because applications that need commands still need the functions.
How do I tell whether a command injection has been exploited?
A strong warning sign is PHP or web server processes that start a shell such as sh or bash. Other signs are unknown processes, new files in upload folders and unusual outgoing connections.