On Windows, this function naively strips special characters and replaces them with spaces. The resulting string is always safe for use with exec() etc, but the operation is not lossless - strings containing " or % will not be passed through to the child process correctly.
Correctly escaping shell commands on Windows is not a simple matter. Programs must consider two distinct escape mechanisms which serve different purposes:
1) The convention used by the CommandLineToArgV() windows system function, used by the child process to interpret the command line string
2) The convention used by cmd.exe to escape shell meta-characters (e.g. output redirection controls)
All commands should be escaped for CommandLineToArgV() - this mechanism is applied to each argument individually before it is appended to the command line string. The resulting string may be safely used with the CreateProcess() family of system functions. However...
In almost all cases when creating a child process from PHP on Windows, it is done indirectly by invoking cmd.exe - this is to enable the use of shell functionality such as I/O redirection and environment variable substitution. As a consequence, the entire command string must be further escaped for cmd.exe. If the executed command contains further indirect calls through cmd.exe, each child command must be escaped again for each level of indirection.
The following functions can be used to correctly escape strings such that they are safely passed through to a child process:
<?php
function escape_win32_argv(string $value): string
{
static $expr = '(
[\x00-\x20\x7F"] # control chars, whitespace or double quote
| \\\\++ (?=("|$)) # backslashes followed by a quote or at the end
)ux';
if ($value === '') {
return '""';
}
$quote = false;
$replacer = function($match) use($value, &$quote) {
switch ($match[0][0]) { case '"': $match[0] = '\\"';
case ' ': case "\t": $quote = true;
return $match[0];
case '\\': return $match[0] . $match[0];
default: throw new InvalidArgumentException(sprintf(
"Invalid byte at offset %d: 0x%02X",
strpos($value, $match[0]), ord($match[0])
));
}
};
$escaped = preg_replace_callback($expr, $replacer, (string)$value);
if ($escaped === null) {
throw preg_last_error() === PREG_BAD_UTF8_ERROR
? new InvalidArgumentException("Invalid UTF-8 string")
: new Error("PCRE error: " . preg_last_error());
}
return $quote ? '"' . $escaped . '"'
: $value;
}
function escape_win32_cmd(string $value): string
{
return preg_replace('([()%!^"<>&|])', '^$0', $value);
}
function noshell_exec(string $command): string
{
static $descriptors = [['pipe', 'r'],['pipe', 'w'],['pipe', 'w']],
$options = ['bypass_shell' => true];
if (!$proc = proc_open($command, $descriptors, $pipes, null, null, $options)) {
throw new \Error('Creating child process failed');
}
fclose($pipes[0]);
$result = stream_get_contents($pipes[1]);
fclose($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[2]);
proc_close($proc);
return $result;
}
$badString = 'String with "C:\\quotes\\" or malicious %OS% stuff \\';
$cmdParts = [
'php',
'-d', 'display_errors=1', '-d', 'error_reporting=-1',
'-r', 'echo $argv[1];',
$badString ];
$wrong = implode(' ', array_map('escapeshellarg', $cmdParts));
$escaped = implode(' ', array_map('escape_win32_argv', $cmdParts));
$cmd = escape_win32_cmd($escaped);
$cmds = [
'escapeshellarg() - wrong' => $wrong,
'escape_win32_argv() - correct for bypass_shell' => $escaped,
'escape_win32_cmd(escape_win32_argv()) - correct everywhere else' => $cmd,
];
function check($original, $received)
{
$match = $original === $received ? '=' : 'X';
return "$match '$received'";
}
foreach ($cmds as $description => $cmd) {
echo "$description\n";
echo " $cmd\n";
echo " original: '$badString'\n";
echo " shell_exec(): " . check($badString, shell_exec($cmd)) . "\n";
echo " noshell_exec(): " . check($badString, noshell_exec($cmd)) . "\n";
echo "\n";
}