PHP, MySQL et la sécurité

Le duo PHP/MySQL étant très populaire, les débutants en développement web y ont souvent recours. Ça fait pas mal de temps que je vois toutes sortes d'immondices sur divers forums. Il y a peu je suis tombé sur un tutoriel vidéo censé apprendre à faire un formulaire d'inscription... tutoriel qui, en plus de comporter de monstrueuses failles de sécurité (injection SQL en particulier, mais pas uniquement), n'utilisait que des mauvaises pratiques de développement. Trop c'est trop, je me fends donc d'un billet afin de tenter de rectifier le tir et montrer à nos jeunes le chemin à suivre.

Quelques notions de sécurité

Injection SQL

L'injection SQL est une faille malheureusement très courante et excessivement dangereuse. Le principe est très simple : l'utilisateur entre des données destinées à modifier la requête SQL et ainsi effectuer des opérations qu'il ne pourrait normalement pas faire. Prennons donc un exemple. Considérons que notre base de donnée test contienne une table users comme suit :

mysql> SELECT * FROM users;
+----+-------+------------------------------------------+
| id | name  | pass                                     |
+----+-------+------------------------------------------+
|  1 | admin | 64faf5d0b1dc311fd0f94af64f6c296a03045571 |
|  2 | derp  | cfc73b1859dd9302de92c6e65558156d805336fc |
+----+-------+------------------------------------------+

Considérons maintenant le code PHP suivant :

<?php
/*
 * /!\ ATTENTION /!\ BUT ÉDUCATIF UNIQUEMENT, NE PAS UTILISER
 * CE CODE CONTIENT DE DANGEREUSES FAILLES DE SÉCURITÉ
 * 
 * /!\ WARNING /!\ EDUCATIONAL PURPOSE ONLY, DO NOT USE
 * THIS CODE CONTAINS DANGEROUS SECURITY FLAWS
*/

if (!empty($_GET['id']))
  {
    $lnk = mysql_connect('localhost', 'root', '********');
    mysql_select_db('test');

    $res = mysql_query('SELECT id, name FROM users WHERE id=' . $_GET['id']) or die(mysql_error());
    while ($row = mysql_fetch_object($res))
      {
        echo $row->id, ': ', $row->name, '<br>';
      }
  }    
else
  {
    echo '<a href="?id=2">click here</a>';
  }

Le code est ici très simple, en donnant un id en get on obtient le nom correspondant :

URL: http://localhost/test/test_1.php?id=2
2: derp

Malheureusement, si un attaquant modifie l'url...

URL: http://localhost/test/test_1.php?id=2%20AND%201=2%20UNION%20SELECT%20name,%20pass%20FROM%20users

admin: 64faf5d0b1dc311fd0f94af64f6c296a03045571
derp: cfc73b1859dd9302de92c6e65558156d805336fc

... il peut alors obtenir n'importe quelle information de la base de donnée. Dans cet exemple on s'en sert pour obtenir les hash de mots de passe et le nom des utilisateurs associés, mais il est possible d'avoir accès à bien plus de choses (y compris les informations contenues dans d'autres bases).

Cross-site scripting (XSS)

Le Cross-site scripting, ou XSS, est une vulnérabilité qui consiste à injecter des données qui vont modifier une ou plusieurs pages du site de manière non prévue, souvent dans le but de voler des identifiants de session ou, dans certains cas, utiliser une autre faille (CSRF par exemple). Reprenons notre base de donnée de l'exemple précédent et considérons script :

<?php
/*
 * /!\ ATTENTION /!\ BUT ÉDUCATIF UNIQUEMENT, NE PAS UTILISER
 * CE CODE CONTIENT DE DANGEREUSES FAILLES DE SÉCURITÉ
 * 
 * /!\ WARNING /!\ EDUCATIONAL PURPOSE ONLY, DO NOT USE
 * THIS CODE CONTAINS DANGEROUS SECURITY FLAWS
*/

if (!empty($_POST['name']) && !empty($_POST['pass']))
  {
    $lnk = mysql_connect('localhost', 'root', '********');
    mysql_select_db('test', $lnk);

    $id = mysql_real_escape_string($_POST['name'], $lnk);
    $pass = sha1($_POST['pass']);
    $res = mysql_query("INSERT INTO users (name, pass) VALUES ('$id', '$pass')", $lnk) or die(mysql_error($lnk));
    if ($res)
      echo 'ok';
    else
      echo 'fail';
  }
else
  {
    echo '<form method="post" action="?"> 
             <input type="text" name="name"><br>
             <input type="password" name="pass"><br>
             <input type="submit">
          </form>';
  }

On essaye, ça fonctionne et, en plus, grâce à notre mysql_real_escape_string() nous avons évité toute injection SQL. Mais rien n'empêche un attaquant d'utiliser un nom spécial...

mysql> SELECT * FROM users;
+----+----------------------------------+------------------------------------------+
| id | name                             | pass                                     |
+----+----------------------------------+------------------------------------------+
|  1 | admin                            | 64faf5d0b1dc311fd0f94af64f6c296a03045571 |
|  2 | derp                             | cfc73b1859dd9302de92c6e65558156d805336fc |
|  3 | toto<script>alert('42')</script> | 403926033d001b5279df37cbbe5287b7c7c267fa |
+----+----------------------------------+------------------------------------------+

Si le nom d'utilisateur est affiché tel quel, le script de l'attaquant se lancera chez le visiteur.

La duplication de données

Si nous utilisons le code de l'exemple précédent, un autre problème se pose : et si un utilisateur rentrait un nom d'utilisateur identique à un autre ? En fonction de comment sera faite la manière de s'identifier sur le site, il sera sans doute possible que la création d'un nouvel utilisateur avec le même nom qu'un utilsiateur déjà existant permette à un attaquant de se faire passer pour cet ancien utilisateur, voire même d'en obtenir les privilèges.

Cross-site request forgery (CSRF)

Vicieux, assez complexe à corriger mais potentiellement puissant si l'attaquant sais ce qu'il fait, on ne pense pas assez au cross-site request forgery. Le principe est extrèmement simple : si, par exemple, sur votre site, vous pouvez supprimer un enregistrement grâce à l'url http://example.com/delete.php?id=<id> (où l'on remplace <id> par un identifiant), alors quelqu'un peut vous faire faire cette deletion sans que vous ne vous en apperceviez. Une technique simple pour le faire est, sur un autre site, d'insérer une balise d'image avec ce lien en source : <img src="http://example.com/delete.php?id=1" alt="pwned" />. En allant chercher l'image, votre navigateur exécutera le script de deletion.

La fausse solution à l'injection SQL

Il est possible de combler une injection SQL comme dans l'exemple du XSS. En effet, la fonction mysql_real_escape_string() est appliquée sur name de manière à échaper tout caractère pouvant servir à fermer le guillemet. Quand au paramètre pass, il n'est pas utilisé tel quel, c'est un hash qui est utilisé, donc aucun problème se ce côté. Ça peut sembler être une bonne solution,… ou pas. Reprennons le premier code :

<?php
/*
 * /!\ ATTENTION /!\ BUT ÉDUCATIF UNIQUEMENT, NE PAS UTILISER
 * CE CODE CONTIENT DE DANGEREUSES FAILLES DE SÉCURITÉ
 * 
 * /!\ WARNING /!\ EDUCATIONAL PURPOSE ONLY, DO NOT USE
 * THIS CODE CONTAINS DANGEROUS SECURITY FLAWS
*/

$res = mysql_query('SELECT id, name FROM users WHERE id=' . mysql_real_escape_string($_GET['id']));

Est-ce que, dans ce cas-ci, mysql_real_escape_string() m'empêche de faire une injection SQL ? Et bien non, vu qu'il n'y a pas de guillemets à fermer, mysql_real_escape_string() ne sert strictement à rien… l'injection SQL de l'exemple fonctionne toujours. Vu que id est un entier, nous aurions du utiliser intval() au lieux de mysql_real_escape_string(). Oui, il est possible de ne pas laisser de failles de ce genre si l'on échape correctement les valeurs, cependant, la création de requête SQL soi même via concaténation de chaînes de caractère ne s'y prête pas bien : une simple erreur d'inatention et c'est une faille qui apparaît. Bref, l'ensemble des fonctions mysql_* est à bannir de tout nouveau développement.

De véritables solutions

Prepared statements

Voici la véritable arme contre les injections SQL : les prepared statement. En PHP, on utilisera de préférence PDO afin de faire ce genre de choses. Le principe des prepared statement : on créé un modèle de requête SQL, puis on y dit quels sont ses paramètres. Un petit exemple étant bien plus parlant, reprenons le tout premier code :

<?php 
if (!empty($_GET['id']))
  {
    $lnk = new PDO('mysql:dbname=test;host=localhost', 'root', '********');

    $req = $lnk->prepare('SELECT id, name FROM users WHERE id = :id');
    $req->bindParam(':id', $_GET['id'], PDO::PARAM_INT);
    $req->execute();

    $res = $req->fetchAll(PDO::FETCH_OBJ);
    if ($res !== false)
      {
        foreach ($res as $row)
          {
            echo $row->id, ': ', $row->name, '<br>';
          }
      }
  }
else
  {
    echo '<a href="?id=2">click here</a>';
  }

Quelques explications :

  • Le new PDO() est ce qui permet de se connecter à la base de données et de sélectionner la base de donnée. Ça remplace nos mysql_connect() et mysql_select_db().
  • Le $lnk->prepare() nous permet de créer un modèle de requête SQL. Ici, à la place d'indiquer l'id, on se contente de mettre un tag :id afin d'indiquer que ce sera ici qu'il se trouvera. Notez que, même pour les chaînes de caractères, il n'y a pas besoin de mettre de guillemets, c'est PDO qui s'en charge.
  • Le $req->bindParam() permet d'associer, pour le modèle de requête que nous avon créé, une valeur au tag. Il nous faut également préciser le type de ce paramètre : PDO::PARAM_INT si c'est un nombre entier, PDO::PARAM_STR si c'est une chaîne de caractères, etc. Consultez la liste des constantes pré-définies pour obtenir tous les paramètres possibles (regardez uniquement celles qui commencent par PDO::PARAM_).
  • Le $req->execute() sert à exécuter la requête que nous avons préparé. Il remplace donc notre vieux mysql_query().
  • Le $req->fetchAll() nous permet de retourner un tableau contenant tous les résultats. Il remplace donc avantageusement mysql_fetch_object() et fonctions équivalentes qui étaient plus complexes d'utilisation.

Conversion des caractères spéciaux

Afin de luter contre le XSS, le plus simple est de convertir les caractères spéciaux en entités HTML. Pour ceci, plusieurs fonctions sont à notre disposition :

On préfèrera htmlspecialchars() qui convertis juste ce qu'il nous faut. htmlentities() convertis plus de choses, cependant c'est superflu et ne fais que gonfler la taille des données pour rien.

Vérification de l'unicité

Une manière simple de corriger le problème de la duplication des données (des noms d'utilisateurs dans l'exemple) est de vérifier si l'utilisateur n'existe pas déjà. C'est un minimum acceptable dans la plupart des cas.

Interdiction des caractères spéciaux

Imaginons un forum sur lequel nous souhaitons empêcher qu'un utilisateur puisse se faire passer pour un autre. Le simple test de l'unicité du nom d'utilisateur, comme vu au point ci-dessus, n'est pas suffisant. En effet, si l'on est autorisé à utiliser des jeux de caractères tels que l'UTF-8, il est possible de trouver des caractères tout à fait ressemblants à nos caractères habituels. Si l'application, elle, ne se trompe pas, un humain sera induit en erreur. Une solution est donc d'interdire les caractères spéciaux. Ceci peut se faire facilement grâce à la fonction iconv() : nettoyer des accents simplement avec Iconv (l'exemple est pour une URL mais rien de plus simple que de l'adapter à nos noms d'utilisateurs).

La distance de Levenshtein

Toujours afin d'éviter que, sur notre beau forum (ou autre), un utilisateur puisse utiliser un nom trop proche de celui d'un autre afin de se faire passer pour ce dernier, je vous présente l'arme ultime : la distance de Levenshtein. Cette distance est un nombre représentant le nombre d'opérations (ajout, retrait, modification, déplacement de lettre) nécessaires pour passer d'une chaîne de caractère à une autre. Une distance de 0 indique donc que les deux chaînes sont identiques, une distance de 1 qu'une seule opération est nécessaire (plop -> plip), et ainsi de suite. En PHP, la fonction levenshtein() permet de calculer cette distance. Pour MySQL, il est possible de créé la fonction apropriée.

Les tokens

Mettre en place un système de tokens permet de luter efficacement contre les attaques CSRF. Le principe est, avant que l'utilisateur ne fasse une action spécifique, de lui générer un token unique (par exemple avec uniqid(), à validité limitée dans le temps (quelques minutes ou heures en général), qui devra obligatoirement etre fourni pour que l'action soit exécutée. Ainsi, un attaquant ne peut exécuter l'action avec le système de l'url de l'image car il n'aura pas pu récupérer un token valide.

Pour aller plus loin