Dès qu'un site web est dynamique, des problèmes de sécurités inhérents au langage de programmation peuvent apparaître. PHP est sans doute le langage le plus proposé par les hébergeurs et le plus présent sur Internet. Cet article va aborder le problème de la sécurité posé par le langage PHP dans un site internet. Peut-on limiter les risques posés par d'éventuelles failles dans le code PHP ? Dans le cadre d'hébergements web mutualisés, quelles mesures un hébergeur peut-il prendre pour qu'un hébergement n'ait pas à pâtir du piètre développement d'un autre site ? Peut-il garantir un minimum de sécurité même en présence de code PHP hostile ? Certaines mesures pourront limiter les risques posés par des bugs de programmation dans le code d'un site web ou dans une application PHP. Elles peuvent être intéressantes à envisager dans le cadre de serveur dédié.

Le Safe Mode

PHP permet notamment de manipuler des fichiers, d'exécuter des commandes et d'ouvrir des connexions réseaux. Ces propriétés le rendent potentiellement dangereux. Cependant, il a été conçu comme une alternative plus sûr aux scripts CGI et au langage Perl. Quand PHP est utilisé comme module Apache, il s'exécute avec les droits de l'utilisateur sous lequel le serveur web fonctionne, généralement l'utilisateur apache. Si différents utilisateurs ont des scripts PHP, tout les scripts se retrouvent avec les mêmes droits! Le safe mode tente de résoudre les problèmes de sécurités des hébergements mutualisés. Faute d'alternatives réalistes au niveau du serveur web ou du système d'exploitation, le safe mode est la mesure principale de sécurité utilisée jusqu'ici.

Pour activer le safe mode, il suffit de mettre safe_mode = On dans le fichier de configuration php.ini et de relancer Apache.

Un système de wrapper sur l'ouverture des fichiers fopen va renforcer les permissions Unix, il vérifie si le propriétaire du script est aussi le propriétaire du fichier et ce n'est qu'à cette condition que le fichier pourra être accédé.

Les exécutions de commandes sont limitées à celles se trouvant dans le répertoire safe_mode_exec_dir.

Seules les variables d'environnement safe_mode_allowed_env_vars peuvent être modifiés à l'exception des variables commençant par les préfixes définis dans safe_mode_protected_env_vars. Par défaut lorsque le safe mode est activé, seules les variables commençant par PHP_ sont modifiables à l'aide de la fonction putenv(). Cela évite les manipulations des variables PATH, IFS, LD_LIBRARY_PATH,... qui pourrait permettre l'execution d'autres programmes que ceux du répertoire safe_mode_exec_dir.

Certaines fonctions dangereuses comme dl permettant le chargement dynamique de librairie sont interdites et divers contrôles sont ajoutés. Par exemple, la fonction chmod() ne peut pas donner les permissions SUID ou SGID, ce qui aurait permis à un utilisateur local de devenir l'utilisateur apache.

Les sessions

Un numéro de session va permettre d'identifier de manière unique un utilisateur. Cette valeur est transmise par un cookie ou via un paramètre dans l'URL. Véhiculé dans une URL, elle peut être transmise involontairement dans un champs Referer HTTP. Pour limiter l'utilisation accidentelle de cette valeur, php peut vérifier la présence d'une chaîne défini par session.referer_check dans le contenu du Referer. Si cette chaîne de texte est absente, le numéro de session n'est pas utilisé. Évidemment, un pirate n'aura aucun mal à forger un Referer ou utiliser un cookie pour exploiter ce numéro de session. Pour se protéger contre cela, il convient de désactiver la gestion des sessions via les URLs: session.use_trans_sid=0 au pris de diminuer la compatibilité avec les clients ne gérant pas les cookies.

En dehors du risque de diffusion du cookie, il y a aussi le risque qu'un pirate fixe le numéro de session, C'est l'attaque par session fixation. Par exemple, le pirate vous invite à suivre un lien vers un site sensible dont l'URL contient un numéro de session. Si l'utilisateur s'identifie sur ce site, le pirate ayant le numéro de session pourra effectuer les mêmes opérations que l'utilisateur. Pour éviter cette attaque, activer session.use_only_cookies=1 (PHP >= 4.3.0).

Si le site http://www.hebergeur.com/site1/ dépose un cookie sur le poste de l'utilisateur, lorsque cet utilisateur va accéder au site http://www.hebergeur.com/site2/, son navigateur va transmettre les cookies du site1! En effet, les deux sites partagent le même domaine. Il faudra que la configuration du serveur distingue le répertoire session.cookie_path pour chaque site hébergé.

Enfin, le répertoire où se trouve les sessions des utilisateurs session.save_path s'il est identique pour tous ne doit pas pouvoir être listé via un script PHP. On verra comment réaliser cela dans le paragraphe suivant. Si chaque site a son propre répertoire pour les sessions, il ne faut pas pour autant que ce répertoire se trouve accessible depuis Internet. Cela peut sembler évident mais cette situation a été observée chez certains hébergeurs.

Isolation au niveau du système de fichier

Malgré la présence du safe_mode, un utilisateur peut se promener sur le système de fichier.

<?php
function list_dir($dirname)
{
  echo "list_dir($dirname)<BR>\n";
  $handle=opendir($dirname);
  if($handle)
  {
    while ($file = readdir($handle))
    {
      print $file."<BR>";
    }       
    closedir($handle);
  }

}

list_dir('/');
?>

Le paramètre open_basedir permet de spécifier une racine en dessous de laquelle aucun fichier ne peut être lu. Il convient de placer ce paramètre pour chaque virtualhost ou site virtuel.

Exemple:
<VirtualHost 1.2.3.4:80>
    ServerName www.toto.org
    ServerAdmin webmaster@toto.org
    DocumentRoot /home/toto/public_html/
    User toto
    Group toto
    php_admin_flag engine on
    php_admin_value open_basedir "/home/toto/"
</VirtualHost>

Et les sessions ?

Le paramètre open_basedir va empêcher un accès direct aux fichiers de session sans pour autant en empêcher le fonctionnement.

Et l'upload ?

La commande copy ne permet pas de déplacer le fichier uploadé car celui-ci se trouve en dehors de la racine open_basedir et de toute façon le safe mode refuserait de copier un fichier n'appartenant pas au propriétaire du script PHP. Il faut utiliser l'instruction dédiée move_uploaded_file

Formulaire HTML d'upload:
<FORM ENCTYPE="multipart/form-data" ACTION="_URL_SCRIPT_PHP_" METHOD="POST">
<INPUT TYPE="hidden" name="MAX_FILE_SIZE" value="1000">
Envoyez ce fichier : <INPUT NAME="userfile" TYPE="file">
<INPUT TYPE="submit" VALUE="Send File">
</FORM>

Code PHP:
<?php 
$uploaddir = '/var/www/uploads/';

print "<pre>";
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploaddir . $_FILES['userfile']['name'])) {
      print "Le fichier est valide, et a été téléchargé 
                 avec succès. Voici plus d'informations :\n";
      print_r($_FILES);
} else {
      echo "Attaque par upload potentielle. Voici plus d'informations :\n";
      print_r($_FILES);
}
print "</pre>";
?>

Attention à la présence de ".." dans le nom du fichier cependant...

Limitation

Coté implémentation, la fonction interne de PHP php_checkuid() valide le propriétaire d'un fichier tandis que la fonction php_check_open_basedir() vérifie si un fichier est bien dans l'un des répertoires de open_basedir. Malheureusement, toutes les fonctions php n'y font pas appel.

Stat

Les fonctions suivantes:

ne vérifient pas la racine open_basedir. Il est donc possible de vérifier la présence d'un fichier et d'obtenir des informations sur celui-ci.

<?php
$file='/etc/passwd';
print '<pre>';
print "Fichier $file\n";
print_r(lstat($file));
print '</pre>';
?>

Module Apache

Le contournement le plus connu utilise la fonction virtual(). Cette fonction est spécifique à Apache, elle est équivalent au Server Side Include <!--#include virtual...--> utilisant le module mod_include

<?php
symlink('/etc/passwd','passwd');
print "<pre>";
virtual('passwd');
print "</pre>";
?>

Elle peut être bloquée par le safe mode qui interdira la création du lien symbolique avec symlink ou en interdisant la fonction virtual(). Pour interdire un ensemble de fonction, il suffit de spécifier dans le fichier php.ini la variable disable_functionsle nom des fonctions séparés par des virgules.

disable_functions = symlink,virtual

Module de compression Bzip2

La fonction bzopen() permet de lire ou créer des fichiers au format bzip2. Le problème est qu'il est possible de créer des fichiers ou d'en lire en dehors de la racine open_basedir. Aucune vérification sur le propriétaire des fichiers n'est effectué. Dans l'exemple suivant, un fichier de documentation est accéder et un fichier est créé dans le répertoire /tmp.

<?php
// open file for reading
$bz = bzopen('/usr/share/doc/HTML/fr/aktion/index.cache.bz2', "r");

// read 10 characters
//print bzread($bz, 10);

// output until end of the file (or the next 1024 char) and close it.  
print bzread($bz);

bzclose($bz);

$filename = "/tmp/testfile.bz2";
$str = "This is a test string.\n";


// open file for writing
$bz = bzopen($filename, "w");

// write string to file
bzwrite($bz, $str);

// close file
bzclose($bz);
?>
$ ls -l /tmp/*.bz2
-rw-r--r--    1 apache   apache         63 mai  2 19:16 testfile.bz2

Module DBA: Database Abstraction layer

PHP dispose d'une couche d'abstraction pour accéder à des bases de données sous forme de fichier.

<?php
$id = dba_open ("/tmp/test.db", "n", "db3");

if (!$id) {
      echo "dba_open failed\n";
          exit;
}

dba_replace ("key", "This is an example!", $id);

if (dba_exists ("key", $id)) {
      echo dba_fetch ("key", $id);
          dba_delete ("key", $id);
}

dba_close ($id);
?>

Il a été possible de créer une base dans le répertoire /tmp, il n'y a pas de vérification de la racine.

$ ls -l /tmp/*.db
-rw-r--r--    1 apache   apache       8192 mai  2 19:19 /tmp/test.db

Module de vérification orthographique PSELL

La librairie pspell permet de vérifier l'orthographe d'un mot, et suggérer des corrections. Il est possible de créer son propre dictionnaire pspell_config_personal ainsi qu'une liste de remplacement pspell_config_repl pour trouver une alternative aux mots mal orthographiés.

<?php
$pspell_link = pspell_new ("en");

$pspell_config = pspell_config_create ("en");
pspell_config_personal ($pspell_config, "/tmp/custom.pws");
pspell_config_repl ($pspell_config, "/tmp/custom.repl");
$pspell_link = pspell_new_config ($pspell_config);

if (pspell_check ($pspell_link, "testt")) {
      echo "This is a valid spelling";
} else {
      echo "Sorry, wrong spelling";
}

pspell_add_to_personal ($pspell_link, "kmaster");
pspell_store_replacement ($pspell_link, "kMasters", "kmaster");
pspell_save_wordlist ($pspell_link);
?>

Le module de vérification peut être utilisé pour créer ces fichiers sur le serveur. PHP ne tient pas compte de la racine spécifié par open_basedir.

$ ls -l /tmp/custom.*
-rw-r--r--    1 apache   apache         34 mai  1 15:20 /tmp/custom.pws
-rw-r--r--    1 apache   apache         46 mai  1 15:20 /tmp/custom.repl

Module CURL: Client URL library

PHP supporte la librairie libcurl qui permet de communiquer via de nombreux protocoles: http, https, ftp, gopher, telnet, dict, file, et ldap. Le protocole file permet d'accéder aux fichiers locaux. Le problème vient du fait que le module CURL peut être utilisé pour accéder à tout fichier lisible par l'utilisateur apache.

<?php
print "<pre>";
$ch = curl_init('file:///etc/passwd');
curl_exec ($ch);
curl_close ($ch);
print "</pre>";
?>

Protection des ressources

Mémoire

Un script PHP peut utiliser de la ressource mémoire jusqu'à concurrence de memory_limit, généralement 8 Mo. Or les options de configuration peuvent être modifié avec les fonctions ini_set(), ini_alter() et ini_restore().

En consultant la table des options, on remarque que certains sont modifiables directement dans le script.

NomPar défautModifiable
safe_mode"0"PHP_INI_SYSTEM
open_basedirNULLPHP_INI_SYSTEM
safe_mode_exec_dir"1"PHP_INI_SYSTEM
memory_limit"8M"PHP_INI_ALL

ConstanteValeurSignification
PHP_INI_USER1La valeur peut être modifiée dans un script
PHP_INI_PERDIR2La valeur peut être modifiée dans le fichier .htaccess
PHP_INI_SYSTEM4La valeur peut être modifiée dans php.ini ou httpd.conf
PHP_INI_ALL7La valeur peut être modifiée n'importe où

Il est ainsi possible de supprimer la limite (-1 = pas de limitation) et consommer la totalité de la mémoire du serveur menaçant ainsi sa stabilité.

<?php
ini_set('memory_limit','-1');
ini_alter('memory_limit','-1');
print "memory_limit ".ini_get('memory_limit')."\n";
$toto="Boom!";
for($i=0;;$i++)
{
    print ".";
    flush();
  $toto.=$toto;
}
?>

Au niveau système, il est possible d'implémenter des limites décrites dans /etc/security/limits.conf activés par le module PAM pam_limits. Le problème est que Apache est lancé sous l'identité root puis qu'un changement d'identité vers l'utilisateur apache est effectué. Le serveur web Apache n'est donc pas affecté par cette limitation car il n'y a pas connexion de l'utilisateur.

Plus simplement, il suffit au niveau de PHP d'interdire les fonctions ini_set(), ini_alter() et ini_restore().

Temps d'exécution

Un script PHP a une durée d'exécution limitée dans le temps. Elle est de max_execution_time secondes, il s'agit de temps machine et non du temps de vie du processus.

Une première méthode est de supprimer la limitation elle-même.

<?php
ini_set('max_execution_time','-1');
ini_alter('max_execution_time','-1');
set_time_limit(-1);
print ini_get('max_execution_time')."<BR>\n";
?>

Pour s'en protéger, il suffit la encore d'interdire les fonctions ini_set(), ini_alter() et ini_restore().

Un contournement possible consiste à faire dormir le processus, il consomme donc de la mémoire mais pas de temps machine.

<?php
sleep(10000);
?>

Certains hébergeurs interdisent en conséquence les fonctions sleep() et usleep().

Arrêter un processus peut laisser le système d'information dans un état instable. Il est donc prévu que le programmeur puisse gérer la fin de son script proprement, il enregistre avec register_shutdown_function la fonction à appeler pour réaliser les opérations critiques.

<?php
function test()
{
  $old_date='';
  print "Debut de test<BR>\n";
  while(1)
  {
    $date=strftime('%c');
    if($date !=$old_date)
    {
      print $date."<BR>\n";
      flush();
      $old_date=$date;
    }
  }
}
register_shutdown_function("test");
test();
print "Fin de test";
?>

Remarque, d'après la documentation de PHP, l'affichage ne doit plus fonctionner après expiration de la minuterie. En pratique, ce n'est pas toujours le cas.

Upload

Un fichier uploadé ne doit pas dépasser la limite upload_max_filesize. Il est cependant possible de découper un fichier, d'uploader chaque tronçon un à un, puis de le réassembler.

Log

Avec la commande syslog(), il est possible de consigner des événements. Un script mal intentionné peut enregistrer des messages en usurpant le nom d'un autre processus de façon à perturber l'analyse des logs. Il peut aussi saturer les logs en utilisant une boucle infinie.

<?php
define_syslog_variables();
openlog("myScripLog", LOG_ODELAY, LOG_LOCAL0);
syslog(LOG_WARNING,"Test");
closelog();
openlog("httpd", LOG_ODELAY, LOG_LOCAL0);
syslog(LOG_WARNING,"Démarrage de httpd  succeeded (test)");
closelog();
?>

Extrait des fichiers de log

mai  6 18:20:35 christophe httpd: Arrêt de httpd succeeded
mai  6 18:20:35 christophe httpd: Démarrage de httpd  succeeded

mai  6 18:20:38 christophe myScripLog: Test
mai  6 18:20:38 christophe httpd: Démarrage de httpd  succeeded (test)

A part si son utilisation est indispensable, je conseille d'interdire la fonction syslog().

Session

Un script peut stocker dans chaque session des données jusqu'à concurrence de l'espace mémoire alloué au processus et ce pour une durée d'un maximum de session.gc_maxlifetime (1440 secondes par défaut, soit 24m). Il est donc difficile de stocker des fichiers dans les sessions et contourner ainsi le système de quota de son compte utilisateur.

Posix

PHP peut être compilé avec les extensions Posix 1 (par défaut sous RedHat). Cela apporte les fonctions qui manquaient autrement.

La fonction posix_kill() permet d'envoyer des signaux aux différents processus. Comme le serveur web et les scripts PHP fonctionnent sous la même identité, il est possible d'arrêter (SIGKILL=9) ou de stopper (SIGSTOP=19) tout les processus Apache sauf le processus père initiale qui tourne en tant que root.

<?php
while(1)
{
  posix_kill(-1,9);
}
?>
<?php
while(1)
{
  posix_kill(-1,19);
}
?>

Un utilisateur malicieux ne pouvant récupérer la liste des utilisateurs via le fichier /etc/passwd dispose d'une autre possibilité: il peut les énumérer avec la fonction posix_getpwuid(). Il interroge le système pour chaque ID et reconstitue ainsi une liste des utilisateurs locaux.

<?php
    for ($i = 0; $i < 6000; $i++)
      {
        if (($tab = @posix_getpwuid($i)) != NULL)
          {
            echo $tab['name'].":";
            echo $tab['passwd'].":";
            echo $tab['uid'].":";
            echo $tab['gid'].":";
            echo $tab['gecos'].":";
            echo $tab['dir'].":";
            echo $tab['shell']."<br>";
          }
      }
?>

Pour se protéger, il est possible d'interdire les fonctions posix_kill() et posix_getpwuid() mais je recommande de ne pas compiler le module POSIX tout simplement.

Scan de port

PHP peut être utiliser pour scanner un serveur. Une première méthode consiste à utiliser une socket fsockopen(), pfsockopen(), socket_connect().

<?php
$server='127.0.0.1';
print "Scan TCP<BR>\n";
for($port=1;$port<655;$port++)
{
  $fp=@fsockopen($server,$port,$errno,$errstr,$port);
  if($fp)
  {
    fclose($fp);
    print "Port $port open<BR>\n";
  }
}
?>

Pour empêcher cette utilisation des sockets, on peut interdire ces fonctions et au besoin les autoriser pour un utilisateur ou plus radicale ne pas compiler PHP avec le support des sockets.

Plus sournois, une autre façon de scanner est d'utiliser une fonction comme ftp_connect. Cette fonction se mettra à bloquer dès qu'elle se connectera sur un port ouvert qui ne répond pas au protocole FTP.

<?php
$ftp_server='localhost';
for($port=1;$port<1024;$port++)
{
  print "Port $port:";
  flush();
  if($conn_id = ftp_connect($ftp_server,$port))
  {
    print "Open\n";
#    ftp_close($conn_id);
  } else
  {
    print "Closed\n";
  }
}
?>

Une protection possible est d'utiliser un firewall sur le serveur et de filtrer les connections sortantes selon l'utilisateur.

iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -p TCP --tcp-flags ! ALL SYN -m state --state NEW -j DROP
...
iptables -A OUTPUT -p TCP -d SERVEUR_MYSQL --dport mysql -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner 48 -m limit --limit 4/s  -j LOG --log-prefix "OUTPUT bad "
iptables -A OUTPUT -m owner --uid-owner 48 -j REJECT
...

Remarque, il peut être nécessaire de recompiler le noyau et/ou la commande iptables pour que le module owner fonctionne.

Les modules

Désactiver les fonctions une à une n'est pas toujours idéal car certaines fonctions pouvant avoir plusieurs noms, on risque d'en manquer. Plus radical, la recompilation de PHP avec uniquement les modules necéssaires est possible mais cette manipulation peut être éviter depuis la version 4.3.2 . Des familles entières de fonctions sont désactivables via le paramètre disable_classes.

Conclusion

Le SafeMode de PHP montre clairement ces limites mais PHP dispose aussi de moyens pour contrer des scripts hostiles. J'espère que cet article vous apportera une aide suffisante pour vous en protéger.


Christophe GRENIER
Consultant Sécurité chez Global Secure
Site perso: http://www.cgsecurity.org
Email perso: grenier@cgsecurity.org