Les injections SQL

tbowan

6 Juillet 2020

Lorsqu’on s’intéresse à la sécurité d’une application Web, l’injection SQL, c’est un peu le B.A.-BA des vulnérabilités. Même si on la connaît depuis au moins 1998, elle n’a jamais quitté le TOP 10 OWASP. En fait, elle est même dans le TOP 1

S’il y a une constante dans le développement d’application, c’est bien la persistance des données. Entre deux exécution de votre programme, comment sauvegarder puis restaurer les données dont il a besoin ? Corolaire : lorsque ces données sont nombreuses, comment les organiser pour retrouver la bonne information rapidement ?

Comme toujours en informatique, les solutions sont nombreuses (et plus ou moins adaptées) mais pour une fois, une solution a tellement bien résolu les problèmes qu’elle est devenue presque systématique : les bases de données SQL.

Pour être précis, on devrait parler de « bases de données relationnelles », SQL étant un langage permettant d’interagir avec ces bases. Ces notions sont à ce point liées l’une à l’autre que, par abus, on parle plus souvent de « base SQL ».

Accéder où on ne devrait pas, illustration de jplenio

Le problème, comme toujours avec ces langages spécifiques, c’est que les utilisateurs pourront vont envoyer n’importe quoi comme donnée et, si le développeur n’y prend pas garde, détourner le fonctionnement normal de son application à leur profit. C’est ce qu’on appelle une injection SQL et ça peut faire de gros dégâts.

Heureusement, depuis le temps qu’on connait cette vulnérabilité, on peut très facilement l’éviter, c’est d’ailleurs étonnant qu’on en trouve encore…

Divulgâchage : lorsque vous voudrez exécuter une requête SQL, utilisez systématiquement les requêtes préparées et n’insérez les paramètres que lors de l’exécution et tout se passera bien.

Les bases de donnée relationnelles

Même si vous n’avez pas besoin d’être expert ès bases relationnelles, il y a tout de même quelques notions minimales que je dois aborder avant d’entrer dans le vif du sujet…

Les tables

L’idée maîtresse avec ces bases de données, c’est qu’on ne stocke pas des informations individuellement mais que ces informations forment des relations (d’où l’adjectif relationnelle), c’est à dire des groupes au sein desquels les informations sont associées pour former un tout portant plus de sens que ses composants.

Bien organiser pour retrouver facilement, illustration de j_cadmus

Individuellement, des titres, des dates et des textes ne signifient rien de particulier, mais une fois en relations, associés les uns avec les autres, ils peuvent décrire des articles de blog.

Pour représenter ces relations, on utilise des tableaux (qu’on appelle table par anglicisme). Les lignes représentent les éléments de la relations (e.g. les articles), les colonnes représentant les informations formant la relation (e.g. leur titre, leur date, …).

Pour les développeurs, les relations peuvent aussi être vues comme des structures (en C) ou encore des objets (en C++, Java, … mais sans les méthodes). Les lignes de la table étant les objets individuels et la table, une façon pratique de stocker et représenter tous les objets de la classe.

Pour rester sur l’exemple des articles, voici à quoi pourrait ressembler une table où ils sont décrits par leur titre (title), leur date (publication en timestamp UNIX pour simplifier), leur contenu (content, du texte brute) ainsi qu’un identifiant (id), facultatif mais bien pratique.

id title publication content
1 Bienvenue 1593691200 Lorem ipsum dolor sit amet, consectetur adipiscing elit.
2 Bonne année 1672531199 Nullam convallis libero ac tellus sagittis congue ut ut ipsum.

Pour ceux qui ne lisent pas les timestamp UNIX de tête, le deuxième article est planifié pour une publication le 31 décembre 2022 à 23h59:59.

Les requêtes

Toutes les interactions avec une base de donnée passent par des requêtes. Ce sont des ordres textuels que le moteur de base de donnée va interpréter puis exécuter sur ses données pour vous fournir le résultat.

Pour les plus jeunes qui n’ont pas connu cette époque, il faut imaginer qu’au XXeXX^e siècle, les pionniers de l’informatique avaient cet espoir un peu fou de pouvoir dialoguer naturellement avec les machines. Les langages et formats inventés à l’époque sont donc généralement très verbeux.

Ainsi, pour créer la table des articles précédents, nous devrions envoyer la requête suivante à notre serveur préféré :

CREATE TABLE articles (
    id          int         AUTO_INCREMENT,
    title       VARCHAR(70) NOT NULL,
    publication int         NOT NULL,
    content     TEXT        NOT NULL,
    PRIMARY KEY(id)
) ;

Une fois la table créée, il faut encore la peupler, ici aussi ce serait via une requête spécifique :

insert into articles (title, publication, content) VALUES
    (
        'Bienvenue',
        1593691200,
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
    ) ,
    (
        'Édito',
        1672531199,
        'Nullam convallis libero ac tellus sagittis congue ut ut ipsum.'
    ) ;

Pour récupérer un article particulier, une autre requêtes serait utilisée :

SELECT * FROM articles WHERE title = 'Bienvenue' ;

Il y a bien sûr d’autres requêtes pour modifier (UPDATE) ou supprimer (delete) les données mais aussi les tables (ALTER pour les modifier, DROP pour les supprimer). Vous voyez l’idée générale…

Dans les applications

Lorsque votre application veut manipuler les données ou la base, elle doit donc construire une requête puis l’envoyer à la base avec un appel à une fonction idoine. Une fois son exécution terminée, le résultat de la requête est retourné à votre application et vous pouvez poursuivre vos opérations.

Envoyer puis attendre la réponse, illustration de Atlantios

Chaque langage et technologie de base de donnée a bien sûr ses petites habitudes mais dans l’ensemble, ça se ressemble toujours. Pour garder les choses simples, les exemples suivants seront en PHP avec une base SQLite.

Pour continuer sur l’exemple du blog, admettons que votre application web affiche le contenu d’un article (dont l’identifiant id est passé en paramètre de l’URL). Après s’être connecté, vous devez créer une requête de sélection (select), l’exécuter puis afficher le résultat.

Voici le genre de code on ne peut plus classique qu’on pourrait rencontrer et qui, comme on va le voir, possède une vulnérabilité d’injection SQL…

// 1. Connexion à la base de donnée
$pdo    = new PDO("sqlite:/var/www/mabase.sqlite") ;

// 2. Génération de la requête SQL
$query  = "select * from articles where "
       .= "id = '" . $_GET["id"] . "' and "
       .= "publication < strftime('%s', 'now')"
    ;

// 3. Envoi de la requête et réception du résultat
$result = $pdo->query($query) ;
$row    = $result->fetch() ;

// 4. Affichage du contenu
if ($row !== false && ) {
    echo "<h1>" . $row["title"] . "</h1>\n" ;
    echo "<p>Publié le : " . date("d/m/Y H:i:s", $row["publication"]) . "</p>\n" ;
    echo $row["content"] . "\n" ;
} else {
    echo "Not Found\n" ;
}

Un utilisateur honnête va passer des valeurs normales à votre script qui va, de son côté, fournir les valeurs attendues. En ligne de commande, voici le genre de chose qu’on pourrait obtenir :

tbowan@nop:~$ curl "http://localhost?id=1"
<h1>Bienvenue</h1>
<p>Publié le : 02/07/2020 10:00:00</p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Et si on souhaite voir les articles en avance, la requête SQL s’occupe du filtrage et on obtient une erreur :

tbowan@nop:~$ curl "http://localhost?id=2"
Not Found

Injection SQL

Vous nous voyez venir… ça n’est pas parce qu’on est sensé mettre un entier dans le paramètre qu’on ne peut pas mettre autre chose 😉. Et en choisissant bien, on peut alors injecter nos propres commandes SQL et détourner l’exécution légitime par nos propres instructions.

Si vous voulez jouer avec cette vulnérabilité, je vous conseille le défi n°14 de Natas voir carrément d’installer DVWA.

Ignorer le reste de la requête

En insérant le caractère de commentaire (-- dans la plupart des cas et # chez mysql). Tout ce qui suivra dans la requête sera alors ignoré.

Retirer un morceau, illustration de skeeze

Si la valeur insérée dans la requêtes est entourée de guillemets simples (ou doubles), il faut également injecter un guillemet simple (ou double) pour simuler la fin de la chaîne et permettre au moteur d’interpréter nos tirets.

Dans notre cas, si un utilisateur envoie la valeur 2'-- pour le paramètre id, cette valeur sera insérée telle quelle dans la requête construite en PHP comme suit :

select * from articles where id = '2'--' and publication < strftime('%s', 'now')

La condition sur la date de publication dans le passé est alors ignorée par la base de donnée, le filtrage n’est donc plus appliqué et tous les articles sont ainsi lisibles.

tbowan@nop:~$ curl "http://localhost?id=2%27--"
<h1>Édito</h1>
<p>Publié le : 31/12/2022 23:59:59</p>
Nullam convallis libero ac tellus sagittis congue ut ut ipsum.

Lire des données

En insérant des fragments de requêtes plus conséquents et en utilisant des UNION, il est aussi possible de récupérer des données d’autres tables…

Voir plus qu’autorisé, illustration de Coernl

En admettant qu’une autre table users stocke les utilisateurs avec leur pseudonyme (username) et leur mot de passe (password), on pourrait insérer un fragment plus adapté et ainsi construire la requête suivante :

L’indentation est rajoutée pour la lisibilité mais en vrai, tout tiendra sur une seule ligne.

select * from articles where id = '-1'
union select
    id,
    username as title,
    0 as publication,
    password as content
from users
where username = "tbowan"
--' and publication < strftime('%s', 'now')

On utilise un identifiant inexistant (-1) pour ne pas sélectionner d’articles puis on ajoute (union) des utilisateurs après avoir pris soin d’adapter les colonnes. En vrai, il faut urlencoder le fragment pour qu’il corresponde à une URL valide mais ça n’est pas bien compliqué…

Pour que la requête soit lisible sur les petits écrans, j’ai du ajouter des retour à la ligne…

tbowan@nop:~$ curl "http://localhost?id=-1%27"\
"%20union%20select"\
"%20id%2C"\
"%20username%20as%20title%2C"\
"%200%20as%20publication%2C"\
"%20password%20as%20content"\
"%20from%20users"\
"%20where%20username%20%3D%20%22tbowan%22"\
"%20--"
<h1>tbowan</h1>
<p>Publié le : 01/01/1970 00:00:00</p>
$2y$10$Yoynw3upeUSzt4A3ouRt1.
V/dAp62uHyhRB2c4e5e2Ad1KIh2b4We

Heureusement que j’ai stocké le mot de passe de manière sécurisée

Mais aussi…

Parfois, l’application et/ou la base est configurée pour accepter des requêtes en séries. En les séparant par un point-virgule on peut alors insérer toutes les requêtes possibles en SQL et avoir un accès en lecture et écriture sur l’ensemble des données.

On peut aussi tout détruire, illustration de stux

Si cet enchaînement n’est pas possible, il faudra alors trouver, dans l’application, des points d’entrées utilisant des requêtes SQL correspondantes et vulnérables à une injection. Par exemple, l’insertion, l’édition ou la suppression d’un article.

Protections

Heureusement, depuis le temps, on a à disposition tout un arsenal de solutions pour éviter, plutôt facilement, ces injections.

Filtrer, c’est déjà ça

Lorsqu’on manipule des données en provenance des utilisateurs, il n’est jamais trop prudent de les valider d’abord. En fait, ce devrait déjà être un réflexe de filtrer toutes les entrées en fonction du type attendu.

Filtrer le contenu, illustration de StockSnap

On peut être tenté de faire ses propres moulinettes mais souvent, les langages disposent déjà de tout ce qu’il faut. En PHP, vous disposez de filter_var() et des filtres de validation qui vérifieront que les données fournies sont au bon format.

Le code précédent pourrait être modifié pour insérer une validation avant la génération de la requête.

// 2.1. Filtrer les entrées
$id = filter_var($_GET["id"], FILTER_VALIDATE_INT) ;
if ($id === false) {
    echo "Bien tenté mais non." ;
    exit(1) ;
}

// 2.2 Génération de la requête SQL
$query  = "select * from articles where "
       .= "id = $id and "
       .= "publication < strftime('%s', 'now')"
    ;

Mais pour sécuriser vos applications, vous ne devriez pas vous reposer uniquement sur le filtrage. Pour deux raisons principales :

Echapper, c’est bien

Puisqu’on doit pouvoir gérer des données textuelles (ou des oublis de filtrage), il faut donc échapper les caractères d’échappement. Encore une fois, chaque langage a ses méthodes.

Protéger individuellement, illustration de annca

Historiquement, on a longtemps utilisé addslashes() qui échappe les caractères qui doivent l’être. C’était d’ailleurs appliqué automatiquement sur les données d’entrées (get, post et cookies) mais en fonction de la configuration… Par défaut avant PHP 5.4.0 puis ensuite seulement si on l’activait…

En plus des problèmes de double échappements (lorsqu’on croit que ça n’a pas encore été échappé) ou d’oubli d’échappements (lorsqu’on croit que ça a déjà été fait), cette fonction ne gère pas les encodages de caractère.

La plupart du temps, vous pouvez vous reposer sur la librairie des fonctions d’accès à la base de donnée qui en contient une qui sait comment bien échapper vos données en prenant en compte toutes les subtilités.

En PHP, je vous conseille la méthode PDO::quote() qui va, en plus d’échapper les caractères, ajouter des guillemets simples autour de notre valeur. Notre exemple pourrait alors être modifié pour spécifier l’encodage de la connexion puis d’échapper le paramètre comme suit :

// 1. Connexion à la base de donnée
$pdo    = new PDO("sqlite:/var/www/mabase.sqlite", "charset=UTF8") ;

// 2 Génération de la requête SQL
$query  = "select * from articles where "
       .= "id = " . $pdo->quote($_GET["id"]) . " and "
       .= "publication < strftime('%s', 'now')"
    ;

Cette fois, il n’est plus possible d’injecter des fragments SQL mais, comme toute solution manuelle, il faudra être très rigoureux pour ne pas oublier un appel puisqu’on ne peut pas le vérifier automatiquement.

Préparer, c’est mieux

Le mieux, ce serait évidement d’utiliser un adaptateur qui sécurise, par construction, les requêtes SQL contre l’injection de fragments dans vos paramètres. Un truc comme pour les injections de commandes ?

Protéger à l’avance, illustration de Wokandapix

Ça tombe bien, ça existe déjà : les requêtes préparées. Vous créez une requête SQL textuelle et, chaque fois que vous allez avoir besoin d’insérer un paramètre, vous le mentionnez. Les paramètres ne seront passé que lors de l’exécution de la requête et seront injectés proprement aux emplacements prévus.

L’autre avantage de ce type de requête, c’est qu’en les envoyant en avance, votre pilote peut tirer parti des caches de requêtes disponibles ce qui, pour les requêtes exécutées plusieurs fois (même avec des paramètres différents) peut vous faire gagner du temps.

Comme toujours, chaque langage a ses petites manies, pour le PHP, c’est la méthode PDO::prepare() qui vous crée la requête que vous pourrez ensuite executer. Dans notre exemple, il faut donc spécifier un paramètre dans la requête puis la préparer avant de l’exécuter avec la valeur du paramètre.

On peut spécifier l’emplacement des paramètres avec un point d’interrogation ? mais je préfère utiliser les deux points suivi d’un nom :id ce qui me permet de les nommer et rend souvent le code plus lisible.

// 2. Génération de la requête SQL
$query   = "select * from articles where "
        .= "id = :id and "
        .= "publication < strftime('%s', 'now')"
    ;

// 3. Envoi de la requête et réception du résultat
$request = $pdo->prepare($query) ;
$request->execute([ "id" => $_GET["id"] ]) ;
$row    = $request->fetch() ;

Cette fois, non seulement on protège contre les injections, c’est détectable par des outils d’analyse de code (en gros, tout appel à query() devient suspect) et en plus, on profite d’optimisation pour gagner du temps d’exécution. Elle est pas belle la vie ?

Et après ?

Vous êtes maintenant capables de détecter ces vulnérabilités et de les corriger mais ce n’est que le début du voyage si vous vous passionnez pour ces injections… En attendant qu’on écrive la suite, vous pouvez patienter avec ces deux autres vulnérabilités web :

Éviter les injections de commandes en PHP

13 janvier 2020 Avec le temps, on en vient tous à lancer des commandes depuis nos applications web. Le problème, c’est lorsque les utilisateurs fournissent des paramètres, il faut alors être particulièrement prudent pour éviter des injections de commandes qui détourneraient votre application. Heureusement, en décorant les fonctions risquées, vous pouvez éviter ces problèmes et même les détecter via votre intégration continue.

Délinéarisation et injection d’objet en PHP

2 avril 2020 Un jour, on a inventé l’idée un peu folle de transférer des objets d’une application à l’autre. Par la suite, on s’est rendu compte que cette possibilité ouvrait de grandes possibilités d’attaque pour qui peut injecter son propre contenu. Finalement, on s’est rendu compte qu’on ne devrait jamais utiliser la délinéarisation.