Aller au contenu

Une recherche qui lit toute la base : l'injection SQL

Temps 2 · séance 1 · ~30 min

Les slides du chapitre (PDF)PDF

  • Expliquer comment une saisie collée dans une requête SQL devient du code.
  • Rejouer l'injection dans la recherche du magasin et lire les comptes des joueurs.
  • Corriger la recherche avec une requête préparée : where() d'Eloquent, ou whereRaw() avec un ? et un tableau.
  • Lire une erreur de PHPStan et dire à quoi sert composer test.

1. TalkTalk : une recherche qui lit toute la base

En octobre 2015, des pirates exploitent une injection SQL sur trois pages web oubliées de l'opérateur britannique TalkTalk et lisent les données de 156 959 clients, dont les coordonnées bancaires de 15 656 d'entre eux. Une injection SQL glisse du code dans une requête de base de données à la place d'une simple donnée. Le régulateur britannique relève que la même faille avait déjà servi deux fois plus tôt en 2015, et inflige 400 000 £, un record à l'époque.

La faille vivait dans des pages héritées d'un rachat, sur une base de données ancienne. Le magasin de votre Tamagotchi a la même : sa recherche d'articles lit toute la base.

2. Le danger : une recherche qui répond trop

Le magasin a un champ « Chercher un article ». Vous tapez un mot, le magasin garde les articles dont le nom le contient. Le contrôleur fait ce tri dans app/Http/Controllers/ShopController.php :

// app/Http/Controllers/ShopController.php
$search = $request->string('q')->toString();

$items = Item::query();

if ($search !== '') {
    $items->whereRaw("name LIKE '%{$search}%'");
}

whereRaw() écrit un bout de SQL brut, tel quel, sans qu'Eloquent le relise. La variable {$search} est le texte tapé dans le champ, collé au milieu de la chaîne SQL. Pour le mot os, la requête lancée est celle-ci, et elle rend le bon article :

select * from "items" where name LIKE '%os%' order by "name" asc

Le mot cherché reste une donnée, entre les apostrophes de la chaîne. Le danger vient d'une saisie qui sort de ces apostrophes. L'attaquant lit alors les adresses e-mail et les empreintes des mots de passe de tous les joueurs, et une variante lit aussi leurs soldes. Le développeur ne l'a pas vu parce que la recherche marche pour les mots sans apostrophe. Une simple apostrophe suffit déjà à la casser. Une saisie bâtie exprès lit ensuite d'autres tables.

3. L'attaque, montrée

Mallory se connecte avec son compte et ouvre le magasin. Elle n'écrit rien dans le code : elle tape dans le champ de recherche.

Une apostrophe casse la requête

Mallory cherche L'os. L'article « L'os à mâcher » existe, elle attend un résultat. À la place, la fenêtre d'erreur d'Inertia s'ouvre sur la page de débogage de Laravel. En local, APP_DEBUG vaut true, et cette page montre tout :

Illuminate\Database\QueryException

SQLSTATE[HY000]: General error: 1 near "os": syntax error
(Connection: sqlite, Database: …\database\database.sqlite,
SQL: select * from "items" where name LIKE '%L'os%' order by "name" asc)

La page ouvre aussi ShopController à la ligne 23, celle qui lance la requête. Le whereRaw fautif est juste au-dessus, ligne 19. L'apostrophe de L'os ferme la chaîne SQL trop tôt. Après '%L', la base lit os%' comme du code, ne le comprend pas, et s'arrête. Cette page de débogage est déjà une fuite : elle donne la requête exacte et le fichier. En ligne, APP_DEBUG vaut false, le sujet du chapitre 2.11.

Une condition toujours vraie

Mallory cherche zzz%' OR '1'='1' --. Aucun article ne s'appelle zzz, et pourtant tout le catalogue revient. Voici la requête lancée :

select * from "items" where name LIKE '%zzz%' OR '1'='1' --%' order by "name" asc

L'apostrophe ferme la chaîne. OR '1'='1' ajoute une condition toujours vraie, donc chaque ligne passe. Les deux tirets -- ouvrent un commentaire SQL, qui efface la fin de la ligne, le order by compris. Un indice le trahit : la liste n'est plus triée par nom.

Lire la table des joueurs

Pour lire une autre table, Mallory ajoute un UNION. Un UNION réunit les résultats de deux SELECT et retire les lignes identiques. Sans tri demandé, leur ordre n'est pas garanti. Les deux SELECT doivent rendre le même nombre de colonnes, que Mallory ne connaît pas. Elle sonde avec une colonne, zzz%' UNION SELECT 1 FROM users --, et la page de débogage répond :

SELECTs to the left and right of UNION do not have the same number of result columns

Mallory ajoute des colonnes jusqu'à ce que l'erreur disparaisse : la table items en a neuf. Elle place les colonnes utiles là où la page les affiche, le nom de l'article et sa description. La saisie qui marche :

zzz%' UNION SELECT id, email, password, 'mood', 0, 0, 0, null, null FROM users --

La requête lancée lit la table users et la colle sous les articles :

select * from "items" where name LIKE '%zzz%' UNION SELECT id, email, password, 'mood', 0, 0, 0, null, null FROM users --%' order by "name" asc

Le magasin affiche trois « articles ». Le titre de chacun est l'adresse e-mail d'un joueur, sa description est le mot de passe haché.

Le magasin affiche trois articles dont les titres sont alice@example.com, mallory@example.com et bob@example.com, et les descriptions sont des mots de passe hachés commençant par 2y dollar 12 dollar, après une recherche contenant UNION SELECT

La saisie n'est plus une donnée cherchée : elle est devenue du code.

La requête assemblée par concaténation. Avec la saisie os, name LIKE '%os%' reste une seule chaîne de texte, la donnée cherchée. Avec la saisie zzz pourcent apostrophe UNION SELECT id, email, password, mood, 0, 0, 0, null, null FROM users deux tirets, l'apostrophe ferme la chaîne '%zzz%', le UNION SELECT id, email, password, mood, 0, 0, 0, null, null FROM users s'exécute comme du code et lit la table users, et les deux tirets mettent la fin de la ligne en commentaire

Cette injection a une limite ici : elle lit la base, elle n'y écrit pas. Mallory tente aussi d'écrire, avec zzz%'; DROP TABLE purchases; -- pour effacer la table des achats. La page ne change pas, la table reste. Le pilote PDO de SQLite ne prépare que la première instruction. Le DROP TABLE ajouté après le point-virgule n'est pas exécuté. Cette limite du pilote ne corrige rien : l'injection lit toujours. Lire suffit à faire mal. Les mots de passe sont hachés, c'est-à-dire transformés en une empreinte qu'on ne relit pas à l'envers. Un attaquant essaie quand même, hors ligne, des millions de mots de passe courants contre cette empreinte jusqu'à en trouver un. Et les adresses e-mail et les soldes, eux, sont lisibles tels quels.

Note

Dans les données fournies, la fabrique calcule l'empreinte de password une seule fois et la réutilise pour les trois comptes. Sur un vrai site, chaque empreinte est calculée avec un tirage au hasard, le sel. Deux joueurs au même mot de passe ont alors des empreintes différentes.

4. L'attaque, testée

On n'attaque que sa propre copie, sur sa propre machine. Connectez-vous avec le compte de Mallory, mallory@example.com et le mot de passe password, puis ouvrez tamagotchi.test/magasin. Dans le champ « Chercher un article », tapez les trois saisies, l'une après l'autre.

  1. L'os : la fenêtre d'erreur s'ouvre sur la requête cassée.
  2. zzz%' OR '1'='1' -- : tout le catalogue revient, sans tri.
  3. zzz%' UNION SELECT id, email, password, 'mood', 0, 0, 0, null, null FROM users -- : les comptes des joueurs s'affichent comme des articles.

5. La correction

Le trou tient en une ligne de ShopController. Avant d'y toucher, lancez les deux contrôles qui le voient déjà.

Le premier est PHPStan. PHPStan lit le code sans le lancer et signale les erreurs de type. L'extension larastan lui apprend les types de Laravel.

vendor/bin/phpstan analyse

Sous PowerShell, écrivez .\vendor\bin\phpstan analyse.

 ------ -----------------------------------------------------------------
  Line   app\Http\Controllers\ShopController.php
 ------ -----------------------------------------------------------------
  19     Parameter #1 $sql of method Illuminate\Database\Eloquent\Builder
         <App\Models\Item>::whereRaw() expects …|literal-string,
         non-falsy-string given.
         🪪  argument.type
 ------ -----------------------------------------------------------------

 [ERROR] Found 1 error

PHPStan attend ici une literal-string : une chaîne écrite par le développeur, ou composée seulement de telles chaînes. La saisie reçue de l'utilisateur ne remplit pas cette condition. Cette erreur arrête aussi composer test, qui enchaîne Pint, PHPStan, puis les tests, comme au chapitre 2.01. Votre tableau de bord des deux séances reste php artisan test, qui lance les tests sans ce contrôle.

Le second contrôle est le test de la faille, tests/Feature/Flaws/Flaw06SqlInjectionTest.php. Un test vérifie qu'une recherche avec une apostrophe rend le bon article, l'autre qu'une recherche ne peut pas réécrire la requête. L'option --filter ne garde que ces deux cas :

php artisan test --filter=Flaw06
   FAIL  Tests\Feature\Flaws\Flaw06SqlInjectionTest
  ⨯ a search with an apostrophe works
  ⨯ a search cannot rewrite the query
  ────────────────────────────────────────────────────────────
   FAILED  … > a search with an apostrophe works
  Expected response status code [200] but received 500.
…
  Tests:    2 failed (10 assertions)

Les deux sont rouges. Le premier échoue sur la 500 de l'apostrophe, le second parce que l'injection OR rend des articles au lieu d'aucun. Sur tout le projet, php artisan test compte 16 rouges.

Corrigez maintenant. La recherche doit traiter la saisie comme une valeur, jamais comme du SQL. Vous connaissez déjà l'outil : c'est la requête préparée, vue au cours de PHP. Dans le texte de la requête, la valeur cherchée est remplacée par un ?. Les valeurs de remplacement sont données à part, dans un tableau. La base reçoit le texte et les valeurs séparément, et elle ne lit jamais une valeur comme du SQL. Ce ? s'appelle un paramètre lié : un emplacement dans le texte SQL, dont la valeur arrive à part.

La ligne trouée fait l'inverse : $items->whereRaw("name LIKE '%{$search}%'"); colle la saisie au milieu du texte SQL. Laravel écrit une requête préparée de deux façons. Ce sont les deux seules à retenir.

Première écriture : where() d'Eloquent, la forme par défaut. Vous donnez la colonne, l'opérateur et la valeur. Laravel écrit lui-même le ? :

// app/Http/Controllers/ShopController.php
$items->where('name', 'like', "%{$search}%");

Pour la recherche L'os, Laravel envoie le texte where "name" like ?, et la valeur %L'os% à part.

Seconde écriture : whereRaw() écrit comme une requête préparée, pour le SQL qu'Eloquent ne sait pas écrire. Vous écrivez vous-même le ? dans le texte, et la valeur dans un tableau :

$items->whereRaw('name like ?', ["%{$search}%"]);

Les deux écritures protègent de la même façon. Dans ShopController, remplacez la ligne trouée par la première, puis relancez les deux contrôles.

Les deux % font partie de la valeur, pas du texte SQL. Pour LIKE, ils veulent toujours dire « n'importe quels caractères ». La recherche trouve donc encore les articles dont le nom contient le mot. Un % tapé par un joueur arrive lui aussi dans la valeur : il élargit la recherche, il ne change pas la requête.

Se prémunir contre l'injection SQL est facile si vous n'oubliez pas ces deux formes : where() d'Eloquent, ou whereRaw() avec un ? et un tableau.

Le paramètre lié. Le code items flèche where name, like, pourcent search pourcent envoie au moteur SQL le texte select from items where name like point d'interrogation d'un côté, et la valeur pourcent zzz pourcent apostrophe UNION SELECT deux tirets pourcent de l'autre. Le moteur met la valeur dans l'emplacement sans la lire comme du SQL, donc la recherche trouve zéro article sans lire la table users

6. La preuve

Relancez les deux mêmes commandes après la correction. PHPStan ne signale plus rien :

vendor/bin/phpstan analyse
 [OK] No errors

Et les deux tests passent :

php artisan test --filter=Flaw06
   PASS  Tests\Feature\Flaws\Flaw06SqlInjectionTest
  ✓ a search with an apostrophe works
  ✓ a search cannot rewrite the query

  Tests:    2 passed (20 assertions)

Sur tout le projet, php artisan test passe de 16 tests rouges à 14. Les 14 qui restent sont les failles d'après, à partir du chapitre 2.07.

Rejouez enfin l'attaque. La même saisie UNION ne rend plus que « 0 article » : la base la cherche comme un texte ordinaire.

Après la correction, le magasin affiche 0 article et le message Aucun article ne correspond, pour la même recherche contenant UNION SELECT

Et L'os retrouve son article : l'apostrophe n'est plus que du texte.

Après la correction, la recherche L'os rend un article, L'os à mâcher, à 1,8 euro

7. La règle

Recopiez cette phrase dans votre carnet des failles.

Une requête SQL ne se construit jamais en collant du texte venant de l'utilisateur. La saisie passe par un paramètre lié.

À retenir

  • Une saisie collée dans une chaîne SQL peut en sortir avec une apostrophe et devenir du code.
  • Un UNION ajouté à une recherche lit d'autres tables et affiche leurs lignes.
  • Une requête préparée remplace la valeur par un ?, le paramètre lié, et donne la valeur à part : elle n'est jamais lue comme du SQL.
  • Eloquent lie les valeurs seul avec where(), et whereRaw() ne le fait que si vous lui passez des ? et un tableau.
  • PHPStan signale un whereRaw() bâti avec une saisie de l'utilisateur, et arrête composer test tant que le trou est là.

Pour aller plus loin