Article

PHP on the Command Line - Part 2

Page: 1 2 3 Next

Security Issues

Programming just wouldn't be any fun if there weren't the constant threat of "haXor ownZ you", would it? Well, the good news is that there's plenty of opportunity, when executing external programs from PHP, to give the world access to your inner sanctum.

Command Injection

Executing external programs with PHP, in which user input affects the program executed, has parallels with the execution of SQL statements: you run the risk of command injection.

Consider the follow example of how not to do it!

#!/usr/local/bin/php  
<?php  
fwrite(STDOUT,"This is insecurity in action!\n");  
 
fwrite(STDOUT,"Enter a filename to list: ");  
 
$file = trim(fgets(STDIN));  
 
$result = shell_exec("ls -l $file");  
 
fwrite(STDOUT, $result);  
 
exit(0);  
?>

Filename: security1.php

See the problem? Imagine that I, as the user of the script, enter something like this at the "Enter a filename to list: " prompt:

Enter a filename to list: whatever.php; rm -rf ./;

Whoops -- no more files in this directory! The command I've executed with shell_exec() will actually look like this:

$result = shell_exec("ls -l whatever.php; rm -rf ./;");

In fact, I'm executing two commands!

One way to prevent this problem occurring in this example would be to place the $file variable in single quotes, and to strip any single quotes the user may have added, like so:

$file = str_replace('\'','',$file);  
$result = shell_exec("ls -l '$file'");

Important Note: do not use double quotes to escape the user-submitted value, because this allows the insertion of environment variables and the like. See When is a command line not a line? for a discussion of parameter expansion and the general CLI security issues involved in mixing with user input.

A much better approach is to use the PHP function escapeshellarg, which ensures that all single quotes in a string are properly escaped, and places a single quote at the start and end of the string.

If we take a script like this:

<?php  
$arg = "foo 'bar' foo";  
echo escapeshellarg($arg)."\n";  
?>

The output displayed will look like that shown below:

'foo '\''bar'\'' foo'

You can use this output safely as a single argument to an external program you're executing.

In other words, the script security1.php above should become:

#!/usr/local/bin/php  
<?php  
fwrite(STDOUT,"Enter a filename to list: ");  
 
$file = trim(fgets(STDIN));  
 
$file = escapeshellarg($file);  
 
$result = shell_exec("ls -l $file");  
 
fwrite(STDOUT, $result);  
 
exit(0);  
?>

Filename: security2.php

Another function provided by PHP is escapeshellcmd(), which escapes meta characters such as ; with a backslash. This provides an alternative to using single quotes, which may be required in some circumstances. In general, though, it's best to use escapeshellarg() unless you have a specific need.

Warning to Windows Users: with PHP versions 4.3.6 and below it is not secure to use escapeshellarg() and escapeshellcmd(). See this alert on Security Tracker.

More generally, as with Web applications, we mustn't simply rely on the escape functions here; we must restrict users to entering only that content that they're required to provide. For example, if they should be entering only a string that contains characters from the alphabet, validate the input with a simple regular expression, like this:

if ( !preg_match('/^[a-zA-Z]*$/', $userinput) ) {  
 exit(INVALID_INPUT);  
}

Shared Hosts

If you're not feeling enough paranoia already, consider what can happen when the arguments you pass to command line script contain sensitive information. For example, consider executing the following:

$ mysqldump --user=harryf -password=secret somedatabase > somedatabase.sql

This makes my username and password available in the list of processes running on the system, and visible to all via the ps command. It's not hard for someone to write a program that harvests this kind of information automatically.

The same problem also applies when we execute programs from a PHP script.

To demonstrate, here's an example the generates a "long wait" (the first assignment for most trainee mechanics is to go ask their supervisor for a long wait...); basically this script will "hang around" long enough for you to see it with the ps command. It's also a nice opportunity to see PEAR::Console_ProgressBar in action:

#!/usr/local/bin/php  
<?php  
# Include PEAR::Console_Getopt  
require_once 'Console/Getopt.php';  
 
# Include PEAR::Console_ProgressBar  
require_once 'Console/ProgressBar.php';  
 
# Exit codes  
define('INVALID_PHP_SAPI',3);  
define('INVALID_LOGIN',4);  
 
# Valid username / password  
define('USERNAME','harryf');  
define('PASSWORD','secret');  
 
//--------------------------------------------------------------------------------------  
/**  
* Displays the usage of this script  
* Called when -h option specified or on illegal option  
*/  
function usage() {  
 $usage = <<<EOD  
Usage: ./longweight.php [OPTION]  
Builds long weights.  
 -l=LENGTH  Length of weight  
 -u=USERNAME  Username  
 -p=PASSWORD  Password  
 -h    Display usage  
 
EOD;  
 fwrite(STDOUT,$usage);  
 exit(0);  
}  
 
//--------------------------------------------------------------------------------------  
/**  
* Gets the options specified by the user  
*/  
function getOptions() {  
 
 $args = Console_Getopt::readPHPArgv();  
 
 // Could be an error with older PHP versions and the CGI SAPI  
 if ( PEAR::isError($args) ) {  
   fwrite(STDERR,$args->getMessage()."\n");  
   exit(INVALID_PHP_SAPI);  
 }  
 
 // Compatibility between "php longweight.php" and "./longweight.php"  
 if ( realpath($_SERVER['argv'][0]) == __FILE__ ) {  
   $options = Console_Getopt::getOpt($args,'hl:u:p:');  
 } else {  
   $options = Console_Getopt::getOpt2($args,'hl:u:p:');  
 }  
 
 // Check for invalid options  
 if ( PEAR::isError($options) ) {  
   fwrite(STDERR,$options->getMessage()."\n");  
   usage();  
 }  
 
 // Set defaults for length, username and password  
 $ret_options = array(  
   'length' => 10,  
   'username'=> NULL,  
   'password'=> NULL  
 );  
 
 // Loop through the user provided options  
 foreach ( $options[0] as $option ) {  
   switch ( $option[0] ) {  
     case 'h':  
       usage();  
     break;  
     case 'l':  
       $ret_options['length'] = $option[1];  
     break;  
     case 'u':  
       $ret_options['username'] = $option[1];  
     break;  
     case 'p':  
       $ret_options['password'] = $option[1];  
     break;  
   }  
 }  
 
 return $ret_options;  
}  
 
//--------------------------------------------------------------------------------------  
/**  
* Validates the user. If not username and password was provided  
* by options, prompts user to enter them  
*/  
function validateUser($username,$password) {  
 if (is_null($username) ) {  
   fwrite(STDOUT,'Enter username: ');  
   $username = trim(fgets(STDIN));  
 }  
 if (is_null($password) ) {  
   fwrite(STDOUT,'Enter password: ');  
   $password = trim(fgets(STDIN));  
 }  
 if ( !(($username == USERNAME) & ($password == PASSWORD)) ) {  
   exit(INVALID_LOGIN);  
 }  
}  
 
//--------------------------------------------------------------------------------------  
/**  
* Returns an instance of PEAR::Console_ProgressBar  
*/  
function & getProgressBar($length) {  
 // Progress bar display (see documentation)  
 $display = 'Weight preparation %fraction% [%bar%] %percent% complete';  
 
 // Indicator for progress  
 $progress = '+';  
 
 // Indicator for what's remaining  
 $remaining = '-';  
 
 // Width of the progress bar  
 $width = '50';  
 
 return new Console_ProgressBar($display,$progress,$remaining,$width,$length);  
};  
 
//--------------------------------------------------------------------------------------  
// Get the command line options  
$options = getOptions();  
 
// Make sure we have a valid user (exits if not)  
validateUser($options['username'],$options['password']);  
 
fwrite(STDOUT, "Preparing Long Weight of length {$options['length']}\n");  
 
$PBar = & getProgressBar($options['length']);  
 
// Loop for the length of the weight  
for ($i=0;$i<$options['length'];$i++) {  
 
 // Update the progress bar  
 $PBar->update($i+1);  
 
 sleep(1);  
}  
 
fwrite(STDOUT,"\nDid you enjoy your long weight?\n");  
 
exit(0);  
?>

Filename: longweight.php

Should you feel the need for a long wait, you can now get one by entering the following from the command line:

$ ./longweight.php -l 30 -u harryf -p secret

The script will hang around in the process list for 30 seconds, with the username and password available for all to see.

I now execute this command with another script, such as:

#!/usr/local/bin/php  
<?php  
shell_exec('./longweight.php -l 30 -u harryf -p secret');  
fwrite(STDOUT,"Long weight finished\n");  
exit(0);  
?>

Filename: execlongweight1.php

Now, from the command line, run the script in the background and examine the process list:

$ ./execlongweight1.php &  
$ ps -eo pid,ppid,cmd | grep longweight

I get output like this:

26466  1589 /usr/local/bin/php ./execlongweight1.php  
26467 26466 /usr/local/bin/php ./longweight.php -l 30 -u harryf -p secret

The way I've formatted the process list displays the process ID in the first column, the parent process id in the second column, and the executing command in the third column. You can see that the execlongweight1.php script was given process ID 26466. You can also see that longweight.php has 26466 as its parent process ID; it was spawned by the execlongweight1.php script. And there's the username and password in full view for anyone who's looking...

Because my longweight.php script also accepts the entering of a username and password interactively, via STDIN, a better approach is to execute the script using popen() in write mode:

#!/usr/local/bin/php  
<?php  
$fp = popen('./longweight.php -l 30','w');  
 
fwrite($fp,"harryf\n");  
fwrite($fp,"secret\n");  
 
fclose($fp);  
 
exit(0);  
?>

Filename: execlongweight2.php

Now, when we execute the cps command, we see the following:

26512  1589 /usr/local/bin/php ./execlongweight2.php  
26513 26512 /usr/local/bin/php ./longweight.php -l 30

The username and password are no longer visible.

Unfortunately, the same won't work with mysqldump, which reads user input from a different source than STDIN. The next-best solution is to place a file called .my.cnf, containing your MySQL username and password, in your Unix account home directory.

[client]  
user=harryf  
password=secret

Filename: ~/.my.cnf

The mysqldump utility will read the username and password automatically from .my.cnf, saving you from having to expose them on the command line.

If you liked this article, share the love:
Print-Friendly Version Suggest an Article

Sponsored Links