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 11.

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.

Sometimes we have to get our hands dirty. MustangJoe @ pixabay

In PHP, we have other functions of the same type that execute a command passed as a parameter, each with its own particularity:

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 like execve:

  1. 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.
  2. 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.

All users are not kind. cocoparisienne @ pixabay.com

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:

Example of legit use.

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:

Example of injected command

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).

Protect arguments, one at a time. Glady @ pixabay

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 possibilities

And 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:

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.

Hide issues with a protective layer. congerdesign @ pixabay

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 😉.