Formation PUB010 : PHP, 2022 Sécuriser le code

21.2 Comment les « hackers » réussissent les injections SQL


Une injection SQL consiste à entrer des caractères précis dans une variable qui sera utilisée dans une requête SQL. Ces caractères feront en sorte que la requête originale sera déviée de son but afin d'ouvrir des portes aux utilisateurs malveillants. Ils pourraient, par exemple, s'authentifier sans connaître le mot de passe, créer un nouvel usager administrateur dont ils connaîtront le mot de passe, détruire une table, bousiller les données, etc.

Mais où ces injections peuvent-elles être entrées ? À n'importe quel endroit où les données peuvent être modifiées par un usager : dans une case de saisie, dans un URL, dans un cookie, etc.

Scénarios analysés

Prenons l'exemple suivant : la page Web effectue une requête SQL pour retrouver les données d'une nouvelle dans la BD à l'aide de l'identifiant reçu en paramètre dans l'URL. 

Attention : vous devez être conscients que les étapes réalisées par les utilisateurs malveillants pour effectuer une injection SQL seront différentes selon le type de requête. Par exemple, si la requête permet de vérifier l'authentification d'un usager, on fera face à une technique différente de celle présentée ici.

Le but de ces explications est de vous aider à comprendre les dangers potentiels et les précautions minimales pour protéger votre site.

Dans cet exemple, nous analyserons différents scénarios : 

  1. La requête est effectuée en utilisant directement le paramètre reçu (pas de protection contre les injections SQL). Le programmeur ne vérifie pas si la requête a fonctionné.
    PHP

    $id = $_GET['id'];

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id=$id";

    $resultat = $mysqli->query($requete);   

     

    if ($mysqli->affected_rows != 0) {   

        $enreg = $resultat->fetch_row(); 

        echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]";   

    else {

        echo "Il est impossible de retrouver les données de la nouvelle.";

    }

  2. La requête est effectuée en utilisant directement le paramètre reçu (pas de protection contre les injections SQL). Le programmeur prend soin de vérifier si la requête a fonctionné mais il a oublié les dangers d'afficher le message d'erreur SQL...
    PHP

    $id = $_GET['id'];

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id=$id";

    $resultat = $mysqli->query($requete);   

     

    if ($resultat) {

        if ($mysqli->affected_rows != 0) {   

            $enreg = $resultat->fetch_row(); 

            echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]";   

        } 

        else {

            echo "Il est impossible de retrouver les données de la nouvelle.";

        }

    }

    else {

        echo "<p>Nous sommes désolés, les données ne peuvent pas être affichées.</p><p>Cause de l'erreur : $mysqli->error</p>";

    }

  3. La requête est effectuée en utilisant directement le paramètre reçu (pas de protection contre les injections SQL). Le paramètre numérique n'est pas entouré d'apostrophes. Le programmeur prend soin de vérifier si la requête a fonctionné.
    PHP

    $id = $_GET['id'];

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id=$id";

    $resultat = $mysqli->query($requete);   

     

    if ($resultat) {

        if ($mysqli->affected_rows != 0) {   

            $enreg = $resultat->fetch_row(); 

            echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]";   

        } 

        else {

            echo "Il est impossible de retrouver les données de la nouvelle.";

        }

    }

    else {

        echo "Nous sommes désolés, les données ne peuvent pas être affichées.";

    }

  4. La requête est effectuée en utilisant directement le paramètre reçu (pas de protection contre les injections SQL). Le paramètre numérique est entouré d'apostrophes. Le programmeur prend soin de vérifier si la requête a fonctionné.
    PHP

    $id = $_GET['id'];

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id='$id'";

    $resultat = $mysqli->query($requete);   

     

    if ($resultat) {

        if ($mysqli->affected_rows != 0) {   

            $enreg = $resultat->fetch_row(); 

            echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]";   

        } 

        else {

            echo "Il est impossible de retrouver les données de la nouvelle.";

        }

    }

    else {

        echo "Nous sommes désolés, les données ne peuvent pas être affichées.";

    }

  5. La requête est effectuée après avoir transtypé le paramètre en entier. Le paramètre numérique n'est pas entouré d'apostrophes. Le programmeur prend soin de vérifier si la requête a fonctionné.
    PHP

    $id = (int)$_GET['id'];

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id=$id";

    $resultat = $mysqli->query($requete);   

     

    if ($resultat) {

        if ($mysqli->affected_rows != 0) {   

            $enreg = $resultat->fetch_row(); 

            echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]";   

        } 

        else {

            echo "Il est impossible de retrouver les données de la nouvelle.";

        }

    }

    else {

        echo "Nous sommes désolés, les données ne peuvent pas être affichées.";

    }

  6. La requête est effectuée après avoir protégé le paramètre à l'aide de addslashes().  Le paramètre numérique est entouré d'apostrophes. Le programmeur prend soin de vérifier si la requête a fonctionné.
    PHP

    $id = addslashes($_GET['id']);

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id='$id'";

    $resultat = $mysqli->query($requete);   

     

    if ($resultat) {

        if ($mysqli->affected_rows != 0) {   

            $enreg = $resultat->fetch_row(); 

            echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]";   

        } 

        else {

            echo "Il est impossible de retrouver les données de la nouvelle.";

        }

    }

    else {

        echo "Nous sommes désolés, les données ne peuvent pas être affichées.";

    }

  7. La requête est effectuée en utilisant directement le paramètre reçu (pas de protection contre les injections SQL). Le programmeur effectue une boucle pour traiter les résultats et ce, même si la requête ne devrait en principe ne retourner qu'un seul enregistrement.
    PHP

    $id = $_GET['id'];

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id=$id";

    $resultat = $mysqli->query($requete);   

     

    if ($resultat) {

        if ($mysqli->affected_rows != 0) {   

            while ($enreg = $resultat->fetch_row()) {  

                echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]"; 

            }  

        } 

        else {

            echo "Il est impossible de retrouver les données de la nouvelle.";

        }

    }

    else {

        echo "Nous sommes désolés, les données ne peuvent pas être affichées.";

    }

  8. La requête est effectuée en utilisant directement le paramètre reçu (pas de protection contre les injections SQL). Le programmeur souhaite optimiser son temps de traitement alors il utilise $mysqli->multi_query() pour effectuer en une seule fois plusieurs requêtes.
    PHP

    $id = $_GET['id'];

    $requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_date FROM nouvelle WHERE nouvelle_id=$id;";

    $requete .= "INSERT INTO historique(histo_description) VALUES ('...')"; 

     

    if ($mysqli->multi_query($requete)) {  // si la première requête a fonctionné  

        if ($resultat = $mysqli->store_result()) {

            if ($mysqli->affected_rows != 0) {   

                $enreg = $resultat->fetch_row();

                echo "<h2>$enreg[0]</h2>$enreg[2] - $enreg[1]"; 

            }  

            else {

                echo "Il est impossible de retrouver les données de la nouvelle.";

            }

        }

        else {

            echo "Nous sommes désolés, les données ne peuvent pas être affichées.";

        }

    }

    else {

        echo "Nous sommes désolés, les données ne peuvent pas être affichées.";

    }

Test 1 : la requête est-elle protégée ?

La première chose que l'utilisateur malveillant fera consiste à vérifier si la requête est protégée ou non. Pour y arriver, il pourrait simplement ajouter un apostrophe à la fin du paramètre.

Ex :

URL

http://mondomaine.com/nouvelle.php?id=1'

Voici les résultats obtenus par les différents scénarios :

  1. La page affiche une erreur PHP. Les « hackers » vont vous adorer !
    Résultat affiché

    Fatal error: Call to a member function fetch_row() on a non-object in C:Program Files (x86)EasyPHP-DevServer-13.1VC11datalocalwebmonsiteweb ouvelle.php on line 27

  2. La page affiche un message d'erreur plus sécuritaire, tel que spécifié dans votre code. Elle affiche ensuite le message d'erreur SQL. À ce stade, rien de trop compromettant mais les « hackers » savent qu'ils ont d'excellentes chances de parvenir à leurs fins.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

     

    Cause de l'erreur : You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''' at line 1

  3. La page affiche un message d'erreur plus sécuritaire, tel que spécifié dans votre code. Cependant, les utilisateurs malveillant savent déjà qu'il y a une chance d'injection SQL car aucun mécanisme ne permet d'ignorer les caractères dangereux comme l'apostrophe.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

  4. Même si votre variable est entourée d'apostrophes dans la requête, la page affiche un message d'erreur plus sécuritaire, tel que spécifié dans votre code. Ici encore, les utilisateurs malveillant savent qu'il y a une chance d'injection SQL. Ils poursuivent donc leur travail d'inspection !
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

  5. Les données s'affichent normalement, sans tenir compte de l'aphostrophe à la fin du paramètre. En effet, lorsqu'une variable est convertie en numérique, elle conserve les chiffres et tout ce qui suit un caractère non numérique est ignoré. L'utilisateur malveillant sait déjà que ses chances d'injection SQL sont diminuées...
  6. Ici encore, les données s'affichent normalement. En effet, la fonction addslashes() ajoute un caractère d'échappement (un « slash » en anglais) devant différents caractères pouvant être interprétés, dont l'apostrophe ( ' ), le guillemet ( " ), la barre oblique inverse, ou backslash ( ) et le caractère NULL. 

    Attention : l'utilisation de addslashes()  n'aura aucun effet si la variable n'est pas entourée d'apostrophes dans la requête.

    Donc, grâce à addslashes(), l'apostrophe ne peut pas être utilisé pour terminer une chaîne de caractère puis ajouter une autre instruction SQL malveillante. L'utilisateur malveillant a, ici aussi, compris que ses chances sont amoindries...

    Il est à noter qu'on aurait pu utiliser mysqli_real_escape_string() au lieu de addslashes() pour obtenir des résultats similaires.

  7. L'utilisation d'une boucle ou d'un if pour traiter les résultat ne change pas grand chose à ce stade. L'utilisateur malveillant sait que la requête n'est pas protégée.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

  8. L'utilisation d'une requête multiple ne change pas grand chose à ce stade. L'utilisateur malveillant sait que la requête n'est pas protégée.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

Attention : la conversion en entier ou l'utilisation de addslashes() ou de mysqli_real_escape_string() n'est pas une protection à toute épreuve. Il s'agit d'une précaution minimale que vous devriez systématiquement utiliser lorsque vous effectuez une requête utilisant une donnée entrée par l'utilisateur.

Test 2 : retrouver le nombre de colonnes utilisées dans la requête SELECT

Une des techniques utilisées pour effectuer une injection SQL consiste à ajouter une clause UNION à la fin de la requête originale. Pour que ça fonctionne, il doit connaître le nombre de champs que la requête originale va chercher. Pour y arriver, il ajoutera, à la fin du paramètre, la clause UNION SELECT NULL. Il augmentera graduellement le nombre d'occurence de NULL jusqu'à ce qu'il n'obtienne plus d'erreur.

Le « hacker » prendra soin de remplacer les espaces par le signe + dans l'URL. Il pourrait également utiliser les caractères %20 pour obtenir un résultat équivalent.

Test 2a

Débutons avec un seul NULL pour voir si la requête ne va chercher qu'un seul champ.

Ex :

URL

http://mondomaine.com/nouvelle.php?id=1+UNION+SELECT+NULL

Voici les résultats obtenus par les différents scénarios :

  1. La page affiche ici encore une erreur PHP. Peu d'indices, mais on poursuit nos tests !
    Résultat affiché

    Fatal error: Call to a member function fetch_row() on a non-object in C:Program Files (x86)EasyPHP-DevServer-13.1VC11datalocalwebmonsiteweb ouvelle.php on line 27

  2. La page affiche votre message sécuritaire suivi du message SQL que les « hackers » attendent : ils savent maintenant que la requête va chercher plus qu'un champ.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

     

    Cause de l'erreur : The used SELECT statements have a different number of columns

  3. La page affiche votre message d'erreur sécuritaire. Les utilisateurs malveillant peuvent déduire que la requête va chercher plus qu'un paramètre ou encore qu'ils doivent refaire le test différemment.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

  4. Les données s'affichent normalement. Mais les utilisateurs malveillants n'ont pas terminé leurs tests... Ils se rappellent que les données n'étaient pas protégées. Ils poursuivent leurs tests.
  5. Les données s'affichent normalement. Passons au test suivant.
  6. Ici encore, les données s'affichent normalement. Passons au test suivant.

  7. L'utilisation d'une boucle ou d'un if pour traiter les résultat ne change pas grand chose à ce stade. L'utilisateur malveillant continuera ses tests pour essayer de trouver le nombre de champs dans la requête.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

  8. L'utilisation d'une requête multiple ne change pas grand chose à ce stade. L'utilisateur malveillant continuera ses tests pour essayer de trouver le nombre de champs dans la requête.
    Résultat affiché

    Nous sommes désolés, les données ne peuvent pas être affichées.

Test 2b

L'utilisateur malveillant poursuivra ses tests en ajoutant plusieurs NULL dans la requête UNION et ce, jusqu'à ce qu'il obtienne un affichage sans erreur. Pour simplifier cette démonstration, nous passerons tout de suite à trois NULL puisque nous savons que la requête va chercher trois champs. 

Ex :

URL

http://mondomaine.com/nouvelle.php?id=1+UNION+SELECT+NULL,NULL,NULL

Avec cette requête, les données sont correctement affichées dans tous les scénarios. Si le « hacker » avait obtenu un message d'erreur avec un ou deux NULL mais n'en obtient pas avec trois NULL, il a la confirmation que la requête va effectivement chercher trois champs.

Une autre technique aurait pu permettre de connaître le nombre de champs : ajouter ORDER BY à la fin de l'URL, suivi d'un nombre correspondant à la position du champ dans la requête. La requête fonctionnera tant et aussi longtemps que ce nombre ne dépasse pas le nombre de champs dans la requête.

Test 3 : obtenir des informations sensibles

Notre utilisateur malveillant va maintenant tenter de faire afficher de l'information à l'aide de la clause UNION.

Test 3a

Pour faire afficher l'information désirée, l'utilisateur malveillant remplacera un ou plusieurs NULL dans la requête précédente par une des informations suivantes :

  • version() : obtient la version de MySQL utilisée (plus facile d'exploiter les failles connues)
  • database() : obtient le nom de la base de données
  • user() : obtient le nom de l'usager MySQL utilisé par le site Web pour accéder à la BD
  • group_concat(table_name) FROM information_schema.tables WHERE table_schema=database() : obtient le nom de chacune des tables de la BD
  • group_concat(column_name) FROM information_schema.columns WHERE table_name= ... : obtient le nom de chaque champ d'une table donnée
  • etc.

Souvent, les « hackers » aiment regrouper plusieurs informations dans une seule colonne à l'aide de la fonction CONCAT_WS(). Ils utiliseront comme séparateur un espace suivi de : suivi d'un autre espace comme suit : char(32, 58, 32).

Ex :

URL

http://mondomaine.com/nouvelle.php?id=1+UNION+SELECT+CONCAT_WS(CHAR(32,58,32),user(),database(),version()),NULL,NULL

Voici les résultats obtenus par les différents scénarios :

  • Pour les scénarios 1 à 6 et 8, les données s'afficheront normalement. En effet, puisque le traitement des résultats n'effectue pas de boucle, seules les vraies données sont traitées. Les données de l'union, qui constituent une seconde ligne dans le tableau des résultat, sont ignorées. Notre utilisateur malveillant devra donc faire des essais supplémentaires pour parvenir à ses fins.
  • Pour le scénario 7, les données obtenues par l'union seront affichées sous les données normales. Ces données piratées ressembleront à ceci :
    Résultat affiché

    monusager@localhost : mabasededonnees : 5.6.12-log

Test 3b

L'utilisateur malveillant soupçonne que les résultats de la requête n'étaient pas traités dans une boucle. Il tentera donc d'obtenir les informations piratées en utilisant la clause LIMIT.

Ex :

URL

http://mondomaine.com/nouvelle.php?id=1+UNION+SELECT+CONCAT_WS(CHAR(32,58,32),user(),database(),version()),NULL,NULL+LIMIT+1,1

Voici les résultats obtenus par les différents scénarios :

  • Pour les scénarios 1 à 3 et 7 et 8, le hacker a réussi à afficher les données piratées :
    Résultat affiché

    monusager@localhost : mabasededonnees : 5.6.12-log

  • Pour le scénario 4, les données s'affichent normalement. C'est que la présence d'apostrophes alentour de la variable fait comprendre à MySQL que toute l'information reçue par le paramètre dans l'URL doit être comparé au champ. Et comme ce champ est numérique, il fait un transtypage et ignore tout ce qui suit le premier caractère non numérique.
  • Pour les scénarios 5 et 6, la requête est protégée soit par la conversion de type, soit par la fonction addslashes() combinée aux apostrophes alentour de la variable dans la requête.

Test 4 : ajouter une requête malicieuse dans l'URL

Les utilisateurs malveillants aimeraient tous pouvoir modifier le mot de passe d'un usager ayant les droits d'administration sur votre site Web. Pour cela, ils essayeront d'effectuer une injection SQL comme suit :

URL

http://mondomaine.com/nouvelle.php?id=1;UPDATE+usagers+SET+password=MD5('monmotdepasse')

Voici les résultats obtenus par les différents scénarios :

  • Pour les scénarios 1 à 7, les données s'afficheront normalement et la requête malicieuse sera ignorée.

    En effet, et c'est très heureux pour nous, la méthode $mysqli->query() ne permet pas d'exécuter deux requêtes de cette façon. En utilisant $mysqli->query() pour effectuer nos requêtes, nous sommes automatiquement protégés contre cette d'attaque en particulier (mais il existe d'autres attaques possibles...).

  • Pour le scénario 8, les données seront également affichées normalement. Cependant, la seconde requête sera elle aussi effectuée. OUCH !!!

    Donc, vous devez être très prudents lors de l'utilisation de $mysqli->multi_query() car cette fonction vous expose aux requêtes malicieuses dans l'URL.

En résumé

Dans la plupart des scénarios, il a été possible soit d'obtenir des informations sensibles, soit carrément d'injecter des données dangereuses dans la base de données. Les seuls scénarios où les données étaient toujours protégées contre les attaques menées sont les suivants :

  • Conversion du paramètre en valeur entière, ce qui fera en sorte que la valeur utilisée dans la requête sera constituée des caractères numériques situés avant le premier caractère non numérique.
  • Utilisation de la fonction addslashes() (ou de mysqli_real_escape_string() ) et présence des apostrophes alentour de la variable dans la requête.

Les scénarios suivants rendaient la tâche du « hacker » plus facile :

  • Utilisation d'une boucle pour traiter les résultats alors que la requête devrait retourner 0 ou 1 enregistrement (on sait cependant qu'à l'aide de la clause LIMIT, le « hacker » serait tout de même parvenu à ses fins)
  • Utilisation de $mysqli->multi_query()

Dans tous les cas, il est important de se rappeler que cette démonstration n'est qu'un exemple d'attaque pouvant être perpétrée sur notre base de données. Il existe plusieurs autres types d'injections SQL et même les meilleurs scénarios de cette démonstration pourraient ouvrir des portes aux « hackers ».

Requêtes préparées

Il existe une technique encore plus sécuritaire pour traiter les paramètres dans une requête SQL : utilisation de requêtes préparées (aussi appelées requêtes paramétrables).

Voici donc le meilleur scénario que vous pourriez utiliser pour vous prémunir contre les injections SQL :

PHP

// 1. Prépare la requête en plaçant un ? à la place de chaque paramètre (variable utilisée dans la requête).

$requete = "SELECT nouvelle_titre, nouvelle_texte, nouvelle_image FROM nouvelle WHERE nouvelle_id=?";

$stmt = $mysqli->prepare($requete);

 

if ($stmt) {

 

    $id = $_GET['id'];

 

    // 2. Indique que le paramètre est de type entier (i) et lui assigne la variable $id.

    $stmt ->bind_param("i", $id);

 

    // 3. Exécute la requête.

    $stmt ->execute();

 

    // Sans cette ligne, il ne sera pas possible de connaître le nombre de lignes retournées par un SELECT.

    $stmt->store_result();

 

    if ($mysqli->num_rows > 0) {  

 

        // 4. Fait le lien entre la position des champs et les variables qui seront initialisées lors du fetch.

        $stmt ->bind_result($titre, $texte, $image);   

 

        // 5. Retrouve les données de la première ligne de résultats.

        $stmt ->fetch(); 

        echo "<h2>$titre</h2>";        

        echo "<img class='imagenouvelle' src='medias/fr/nouvelles/$image' alt='$titre' />$texte";   

 

    } 

    else {

        echo "<div class='messageerreur'>Il est impossible de retrouver les données de la nouvelle.</div>";

    }

 

    // 6. Libère la mémoire.

    $stmt ->close();

}

else {

    // arrivera ici s'il y a une erreur dans la requête (ex : mauvais nom de champ).

    echo "<div class='messageerreur'>Nous sommes désolés, la nouvelle ne peut pas être affichée.</p>";

    // on affichera le message d'erreur MySQL seulement si on est en mode débogage donc ne sera jamais fait en ligne.

    echo_debug($mysqli->error);

}

L'utilisation de requêtes préparées est souhaitable dans tous les langages de programmation qui le permettent!

Pour plus d'information

« Les requêtes préparées ». PHP. http://www.php.net/manual/fr/mysqli.quickstart.prepared-statements.php

« What is SQL Injection? ». Infosec institute. http://resources.infosecinstitute.com/dumping-a-database-using-sql-injection/

« URL Based SQL Injection ». Ethical Hacking Tutorials. http://kyrionhackingtutorials.com/2012/01/url-based-sql-injection/

« Blind SQL Injection ». OWASP. https://www.owasp.org/index.php/Blind_SQL_Injection

« Roberto Salgado's Knowledge Base ». Websec. http://websec.ca/kb/sql_injection

« MySQL SQL Injection Cheat Sheet ». pentestmonkey. http://pentestmonkey.net/cheat-sheet/sql-injection/mysql-sql-injection-cheat-sheet

« SQL Injection cheat sheet Esp: for filter evasion ». ha.ckers. http://ha.ckers.org/sqlinjection/

▼Publicité

Veuillez noter que le contenu de cette fiche vous est partagé à titre gracieux, au meilleur de mes connaissances et sans aucune garantie.
Merci de partager !
Soumettre