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, ouwhereRaw()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é.

La saisie n'est plus une donnée cherchée : elle est devenue du code.
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.
L'os: la fenêtre d'erreur s'ouvre sur la requête cassée.zzz%' OR '1'='1' --: tout le catalogue revient, sans tri.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.
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.

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

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
UNIONajouté à 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(), etwhereRaw()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êtecomposer testtant que le trou est là.
Pour aller plus loin
- Alexander J Martin, « TalkTalk gets record £400k slap-slap from Brit watchdog », The Register, 5 octobre 2016, https://www.theregister.com/2016/10/05/ico_finally_delivers_record_400k_fine_to_talktalk_over_breach/
- Phil Muncaster, « ICO Slaps TalkTalk With £400K Fine After Breach », Infosecurity Magazine, 6 octobre 2016, https://www.infosecurity-magazine.com/news/ico-slaps-talktalk-with-400k-fine