Avoid Command Injection in PHP
Spoiler: Over time, we all start issuing commands from our web applications. The problem is when users provide parameters, you have to be particularly careful to avoid command injections that would hijack your application. Fortunately, the solution is easy to implement.
When developing a web application, there always comes a time when we would like to launch a system command or a local program. On small projects, we do very well without it, but as the project grows, the probability of needing it tends towards .
Most of the time, we can find solutions in our favorite programming language by finding equivalents or by recoding the command. But sometimes, the cost of development, or rather that of maintenance, dissuades us and we then naturally end up launching external commands.
The problem, as we will see today (in PHP
), is when we
use data provided by visitors. As they are not all necessarily
nice, some might will insert garbages to hijack our
beautiful application and make it execute what they want.
Caution is essential and errors are costly.
The good news is that these specific command injection problems can be avoided relatively easily. To the point that we can even automate the verification and guarantee secure code…
Executing commands
When you need to execute an external command or program, many
functions are often available. For example, in C
, we can use execve() on Linux or ShellExecuteA() on
Windows.
In PHP, we have other functions of the same type that execute a command passed as a parameter, each with its own particularity:
- passthru()
and system()
pass the output of the command (what it would display) directly to the
application client in the web response,
system()
provides, in addition, the return code of the command, - shell_exec()
and exec()
return the output of the command in string to allow to manipulate it,
exec()
also provides the return code of the command, - proc_open() gets your hands dirty and allows finer control of the process.
For example, if you want to list (ls
command) all files
and directories (-a
option) in detail (-l
option) in increasing order (-r
option) of creation
(-t
option), you could use this piece of code (it’s
actually the
official example):
<?php
$output = shell_exec('ls -lart');
echo "<pre>$output</pre>";
?>
From experience, we most often encounter shell_exec()
because it feeds the majority of cases: launching a command, getting its
output to manipulate it and continuing execution accordingly. The other
functions (passthru()
, system()
,
exec()
and especially proc_open()
) are much
more specific and are therefore encountered less often.
To be more complete, you could also encounter, in
PHP
, a last variant pcntl_exec() which works likeexecve
:
- Rather than providing a complete command line, this function asks you to split it; providing the path to the program and then tables for the arguments and environment variables. It will not be vulnerable to injection.
- It will replace the current process with the called program, it is therefore not possible to recover the output to manipulate it and continue execution in
PHP
.It is therefore only available if
PHP
is launched in CLI (command line) or CGI (separate process launched by the web server). We therefore encounter it even more rarely than previous versions.
Command injection
Who says application, says user data. The commands that you are going to launch therefore use, directly or indirectly, data provided by users. After more or less manipulation but most of the time, part of the command depends on what the user provides you.
Here is sample code, loosely simplified from the shell injection
exercises of Damn
Vulnerable Web Application . Here, we invite a visitor to send an
ICMP ECHO REQUEST
(a ping) to a machine of their
choice (this type of application really
exists).
if( isset( $_REQUEST['ip'] ) ) {
$target = $_REQUEST[ 'ip' ];
$output = shell_exec( "ping -c 4 {$target}");
echo "<pre>{$output}</pre>\n" ;
}
A normal user will provide an IP address to know if a machine is reachable and if the network is working… A bit like with the following request:
Which can just as easily be run on the command line with
curl
. It’s less visual, but allows everyone to read it:
tbowan@nop:~$ curl "http://localhost?ip=192.168.1.1"
<pre>PING 192.168.1.1 (192.168.1.1) 56(84) bytes of data.
64 bytes from 192.168.1.1: icmp_seq=1 ttl=63 time=1.50 ms
64 bytes from 192.168.1.1: icmp_seq=2 ttl=63 time=1.36 ms
64 bytes from 192.168.1.1: icmp_seq=3 ttl=63 time=1.13 ms
64 bytes from 192.168.1.1: icmp_seq=4 ttl=63 time=1.03 ms
--- 192.168.1.1 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3003ms
rtt min/avg/max/mdev = 1.032/1.258/1.500/0.187 ms</pre>
But a less nice user could add their own commands. For
example, providing the ;uname -a
parameter to get system
information:
Which can of course also be launched with curl
. In this
case, you must pass the parameter uname%20-a
, the space
must be url encoded (translated into %20
) to be
managed by the web server:
tbowan@nop:~$ curl "http://localhost?ip=;uname%20-a"
<pre>Linux nop 4.15.0-72-generic #81-Ubuntu SMP Tue Nov 26 12:20:02 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux</pre>
This works because once added to the command passed to
shell_exec()
, we actually execute the following command
line:
pinc -c 4 ;uname -a
The ;
separates the line into two commands. First
ping
, which will fail silently (the error is not on
standard output but in log files like
/var/log/apache2/error.log
). Then uname
, the
output of which will be returned to us.
You can imagine that if we can do that, we can do everything (with the rights of the web server): Read, write and execute files, commands, etc. Depending on the objective, it will be more or less discreet and more or less less destructive.
Argument protection
The problem is that the command execution functions do not know how to differentiate between your arguments and those of users who want to hijack your application. When in doubt, he treats everything the same way, telling itself that it’s your problem (and it’s right).
If you want to filter special characters and that sort of thing yourself, I don’t recommend it because, as with cryptography, tinkering with something yourself never really works. Other example command injection exercises can show you why:
- Natas 9 which serves as a basis and does not filter anything (like the previous example),
- Natas 10 which filters the input and disallows
&
and;
but since it doesn’t filter|
you can still pass through,- Natas 16 which filters even more but forgets the
$
, which still leaves you some possibilitiesAnd we haven’t even dealt with the pollution of parameters which consists, by adding spaces and quotes (single or double depending on the case) of adding several parameters at once and diverting the uses of the commands used in your applications. Technique usable on Natas 10 if they had not forgotten any specific character.
The thing is that PHP
provides two functions to
escape problematic characters and avoid command injections. So
rather than recoding your own wheel, you might as well use the one
already available:
- escapeshellcmd() which escapes, in a complete command line, characters with a specific meaning (Linux and Windows are supported) , but it does not avoid the pollution of arguments,
- escapeshellarg() which protects parameters individually so that they cannot be injected, but it requires to use it on each parameter (at least those provided by the user).
If we comme back to the ping
example, the fix simply
consists of using escapeshellarg()
on the
$target
parameter since it is provided by the user:
if( isset( $_REQUEST['ip'] ) ) {
$target = $_REQUEST[ 'ip' ];
$output = shell_exec(
'ping -c 4 '
. escapeshellarg($target)
;
)echo "<pre>{$output}</pre>\n" ;
}
This time, no more injections possible. On the other hand, you will have to go through all your calls and manually check the arguments that require escaping.
Decoration of commands
But we can go further by decorating
shell_exec()
with a layer of automatic escapes on all the
parameters to be passed to the command.
For the example, here I use a variadic function (allowing to manage an indefinite number parameters):
function escaped_shell_exec($cmd, ...$args) {
$line = escapeshellarg($cmd);
foreach ($args as $arg) {
$line .= " " . escapeshellarg($arg);
}return shell_exec($line);
}
Far-fetching: Escaping the command name itself (like I did here) is up for discussion…
There is no reason to let a visitor provide the command name (e.g. he might specify
/sbin/shutdown
to shut down the server, that sort of thing), and if you never let user to add data in the command name, no need to escape it…If you needed to do it anyway (this would be a design fault in my opinion) you would need specific filtering on the command with a white list of only authorized commands. With a whitelist, no need to escape.
But since my function can’t know what context you are in, we can imagine that the user provides part of the command name and without a whitelist to check, so I prefer to escape that too.
With this decorated function, the ping
example changes
again to use our function and split the command line into individual
parameters (I find it more readable by the way):
if(isset($_REQUEST['ip'])) {
$target = $_REQUEST[ 'ip' ];
$output = escaped_shell_exec(
'ping',
'-c', 4,
$target
;
)echo "<pre>{$output}</pre>";
}
If we retry a command injection, it will fail because the injected
parameter (;uname -a
) is considered as a parameter and is
no longer interpreted. If you try it anyway, the ping
will
fail with an error message, visible in the web server error logs:
tbowan@nop:~$ tail -n 1 /var/log/apache2/error.log
ping: ;uname -a: Name or service not known
The advantage of safe-by-design decoration
(i.e. escaped_shell_exec()
) is that we can then use static
code analysis tools to search and find calls to decorated
functions (and vulnerable, shell_exec()
). A simple
egrep
on your codebase should not return any invocation
other than the decorated ones:
egrep "\Wshell_exec" -r *
And after ?
The decoration that I proposed to you here, with
escaped_shell_exec()
is incomplete. On the one hand I do
not manage the other functions (passthru()
,
system()
, exec()
and proc_open()
)
and the passing of their parameters. On the other hand, I did not take
into account input-output redirections (e.g.
2>&1
) nor command sequences (e.g.
;
) when they are required. It’s not impossible to do, but
it was out of scope for today. Maybe another time 😉.