Chapitre 6.2 — SQLi & requêtes préparées
⏱️ TL;DR — L’injection SQL est l’injection reine : elle peut lire toute la base (comptes, mots de passe hachés, données personnelles), contourner l’authentification, modifier ou détruire des tables. La parade est la requête préparée (paramétrée) :
WHERE email = ?+ la valeur passée à part. Côté PHP, c’est PDO avecprepare/execute; côté Symfony, Doctrine (paramétré par défaut, mais on peut le casser). Attention : le paramétrage protège les valeurs, pas les identifiants (noms de colonnes/tables) ni la direction d’unORDER BY— pour ceux-là, on utilise une allow-list. Et on n’utilise jamais l’interpolation de chaîne dans une requête, même « juste pour un nombre ».
🎯 Objectifs
- Mesurer l’impact d’une SQLi (lecture massive, bypass d’auth, altération).
- Écrire des requêtes préparées en PDO et comprendre Doctrine (QueryBuilder, DQL,
setParameter). - Savoir ce que le paramétrage ne couvre pas (identifiants,
ORDER BY) et gérer ces cas par allow-list. - Éviter les faux amis : interpolation « pour un entier »,
LIKE, requêtes natives.
Ce qu’une SQLi permet vraiment
Une injection SQL n’est pas qu’un « bug » : selon le contexte, elle donne à l’attaquant un pouvoir considérable sur la base :
- Exfiltration massive : lire des tables entières via
UNION SELECT, y compris les comptes et les hashes de mots de passe (qu’il craquera hors-ligne d’autant plus vite qu’ils sont mal hachés — d’où la Partie 3). - Contournement d’authentification : une clause
WHERE email = '...' AND password = '...'transformée par' OR '1'='1' --peut valider une connexion sans mot de passe correct. - Altération / destruction :
UPDATE,DELETE,DROPsi le contexte le permet (requêtes empilées, procédures). - Injection aveugle (blind) : même sans voir le résultat, l’attaquant déduit les données bit par bit via des différences de réponse (booléenne) ou de temps (time-based) — l’absence de message d’erreur ne protège pas.
Bref, une seule requête vulnérable peut compromettre l’ensemble des données. D’où la priorité absolue de la fermer.
La parade : requêtes préparées
En PHP : PDO
PDO (PHP Data Objects) est l’interface standard. La forme sûre : prepare (le code, avec des ? ou des :noms) puis execute (les valeurs, à part).
// ✅ PDO prepare : la valeur est un parametre, jamais du code
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $_POST['email']]);
$user = $stmt->fetch();
// avec des placeholders positionnels
$stmt = $pdo->prepare("INSERT INTO cours (titre, formateur_id) VALUES (?, ?)");
$stmt->execute([$titre, $formateurId]);Bonnes pratiques PDO : activer le mode d’erreur en exceptions (PDO::ERRMODE_EXCEPTION) pour ne pas ignorer silencieusement un échec, et désactiver l’émulation des requêtes préparées (PDO::ATTR_EMULATE_PREPARES => false) pour un vrai prepare côté serveur SQL.
⚠️ Piège —
mysqli/PDO permettent hélas encore de concaténer. Voir$pdo->query("... $x ...")ou$pdo->exec("... $x ...")avec une variable d’entrée = danger.query()/exec()exécutent une chaîne telle quelle : ils n’ont de sens qu’avec des requêtes sans entrée utilisateur. Dès qu’une entrée intervient, c’estprepare()+execute(). Ne te laisse pas piéger par le fait que « ça marche » en test avec des données propres.
En Symfony : Doctrine
Doctrine (ORM/DBAL) paramètre par défaut : le repository, le QueryBuilder et le DQL utilisent des paramètres liés. Tant que tu passes les entrées via setParameter, tu es protégé.
// ✅ QueryBuilder : parametre lie, sur
$qb = $repo->createQueryBuilder('c')
->where('c.titre = :titre')
->setParameter('titre', $titre) // valeur passee a part
->getQuery()->getResult();
// ✅ DQL avec parametre
$query = $em->createQuery('SELECT u FROM App\Entity\User u WHERE u.email = :email');
$query->setParameter('email', $email);Mais on peut casser cette sécurité en concaténant dans le DQL ou dans une requête SQL native :
// ❌ On a CONTOURNE la protection en concatenant dans le DQL
$dql = "SELECT u FROM App\Entity\User u WHERE u.email = '" . $email . "'"; // VULNERABLE
// ❌ Requete native concatenee
$conn->executeQuery("SELECT * FROM users WHERE email = '$email'"); // VULNERABLELa règle : même avec un ORM, toujours passer les entrées par setParameter (ou les paramètres de executeQuery), jamais les concaténer. L’ORM protège si on l’utilise correctement.
Ce que le paramétrage NE couvre PAS
Les paramètres liés remplacent des valeurs (un email, un nombre, une date). Ils ne peuvent pas remplacer des identifiants SQL — un nom de table, un nom de colonne, ou la direction d’un tri (ASC/DESC). Ces éléments font partie de la structure de la requête, pas de ses données. Or on a parfois besoin de les rendre dynamiques (un tri choisi par l’utilisateur : « trier par date / par nom »).
// ❌ On ne peut pas parametrer un nom de colonne -> tentation de concatener -> injection
$sql = "SELECT * FROM cours ORDER BY " . $_GET['tri']; // VULNERABLE (tri = "date; DROP TABLE ...")La parade ici n’est pas le paramétrage (impossible) mais l’allow-list : on n’autorise qu’un ensemble fini de valeurs connues, et on refuse tout le reste.
// ✅ Allow-list : seule une valeur d'une liste connue est acceptee
$colonnesAutorisees = ['date' => 'created_at', 'nom' => 'titre'];
$sens = ($_GET['sens'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';
$colonne = $colonnesAutorisees[$_GET['tri'] ?? 'date'] ?? 'created_at'; // fallback sur
$sql = "SELECT * FROM cours ORDER BY $colonne $sens"; // valeurs 100% controlees par NOUSLe principe : l’entrée utilisateur ne sert qu’à choisir une clé dans un dictionnaire dont nous contrôlons les valeurs SQL. Jamais l’entrée brute dans la structure de la requête.
💡 Réflexe — Deux mécanismes selon ce qui est dynamique : pour une valeur (filtre, insertion), paramètre lié ; pour un identifiant/structure (colonne de tri, table,
ASC/DESC), allow-list (dictionnaire de valeurs autorisées). Si tu te surprends à vouloir concaténer une entrée dans une requête « parce qu’on ne peut pas la paramétrer », c’est le signal d’une allow-list — pas d’une concaténation.
Faux amis et cas retors
- « C’est juste un entier » : même pour un id numérique, on paramètre (
WHERE id = ?). Ne jamais interpoler « parce que c’est un nombre » — un$_GETreste une chaîne contrôlée par l’attaquant, et l’habitude de concaténer est le vrai danger. Au minimum, uncast/validation stricte et un paramètre. LIKE: les caractères%et_sont des jokers ; on paramètre la valeur et on échappe ces jokers si l’utilisateur ne doit pas piloter le motif (WHERE titre LIKE :qavec:q = '%' . escapeLike($terme) . '%').- Listes
IN (...): on génère autant de placeholders que d’éléments (IN (?, ?, ?)) et on passe le tableau — jamaisIN (+ join(entrees) +). - Requêtes natives / procédures : mêmes règles, les paramètres liés existent aussi côté DBAL/PDO natif.
🎯 Côté attaquant — L’attaquant repère une SQLi en injectant une apostrophe et en guettant une erreur SQL (souvent affichée en clair par des applis mal configurées — d’où l’importance de masquer les erreurs en prod, chapitre 6.4). Puis il escalade :
UNION SELECTpour lire d’autres tables, injection booléenne ou temporelle (blind) quand rien ne s’affiche, jusqu’à dumper la base. Les points les plus visés : les champs de recherche, les filtres, le tri, et surtout tout paramètre qui « ressemble » à un identifiant repris dans une requête.
🧭 Sur FormaCampus — L’API Symfony de FormaCampus utilise Doctrine avec
setParameterpartout ; aucune concaténation en DQL ni en SQL natif (vérifié en revue). Le tri des listes (cours, apprenants) est piloté par une allow-list (dictionnaireclé → colonne), jamais par l’entrée brute. Les erreurs SQL sont masquées en production (page générique, détail dans les logs internes). Côté Moodle, c’est l’API$DBparamétrée (chapitre Partie 11). Un test de sécurité rejoue des payloads classiques (' OR '1'='1,'; --) sur les endpoints de recherche et vérifie qu’ils sont traités comme de simples chaînes.
✏️ Exercices
Exercice 1 — Bypass d’authentification. Une connexion fait : "SELECT * FROM users WHERE email='$e' AND pass='$p'". Montre une valeur de $e qui connecte sans connaître le mot de passe, puis corrige.
✅ Solution
Avec $e = admin@site.fr' -- (et n’importe quel $p), la requête devient : SELECT * FROM users WHERE email='admin@site.fr' -- ' AND pass='...'. Le -- commente la vérification du mot de passe : la requête sélectionne l’utilisateur admin sans contrôler pass → connexion réussie. (Variante : ' OR '1'='1' -- renvoie le premier utilisateur.) Correctif : requête préparée — prepare("SELECT * FROM users WHERE email = ? AND pass_hash = ?") — et, en réalité, on ne compare pas le mot de passe en SQL : on récupère l’utilisateur par email (paramétré), puis on vérifie le hash avec password_verify (Partie 3). Le mot de passe ne doit jamais transiter en clair dans une clause SQL.
Exercice 2 — Tri dynamique sûr. Un endpoint accepte ?tri=nom|date et ?sens=asc|desc pour ordonner une liste. Écris une implémentation sûre.
✅ Solution
On ne peut pas paramétrer une colonne/direction : on utilise une allow-list. Exemple :
$mapCol = ['nom' => 'titre', 'date' => 'created_at'];
$col = $mapCol[$_GET['tri'] ?? 'date'] ?? 'created_at'; // cle -> colonne controlee
$sens = strtolower($_GET['sens'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';
$stmt = $pdo->prepare("SELECT * FROM cours ORDER BY $col $sens");
$stmt->execute();L’entrée ne fait que choisir une clé dans un dictionnaire dont nous fixons les valeurs SQL ; $sens est réduit à ASC/DESC. Aucune entrée brute n’atteint la structure de la requête → pas d’injection, même si le reste ($col $sens) est interpolé (car ses valeurs sont 100 % maîtrisées par nous).
🧠 Quiz de révision
1. Cite trois choses qu’une injection SQL permet.
Par ex. : exfiltrer des tables entières (comptes, hashes, PII) via UNION ; contourner l’authentification (' OR '1'='1' -- ) ; modifier/détruire des données (UPDATE/DELETE/DROP). Même en aveugle (blind), l’attaquant déduit les données par réponses booléennes ou temporelles.
2. Quelle est la forme sûre en PDO ?
prepare() (le SQL avec des placeholders ?/:nom) puis execute([...valeurs]) — les valeurs passées séparément et traitées comme de pures données. On évite query()/exec() avec une entrée, et on active les exceptions + désactive l’émulation.
3. Doctrine protège-t-il automatiquement de la SQLi ?
Si on l’utilise correctement : QueryBuilder/DQL avec setParameter paramètrent par défaut. Mais on casse la protection en concaténant une entrée dans le DQL ou dans une requête SQL native. La règle : toujours setParameter, jamais de concaténation.
4. Que ne peut-on pas paramétrer, et que fait-on alors ?
On ne peut pas paramétrer les identifiants (noms de table/colonne) ni la direction d’un ORDER BY — ils font partie de la structure. Pour les rendre dynamiques en sécurité, on utilise une allow-list : l’entrée choisit une clé dans un dictionnaire de valeurs SQL que nous contrôlons.
5. Faut-il paramétrer même « juste un entier » ?
Oui : on paramètre WHERE id = ? (et/ou on valide/cast strictement). Un $_GET reste une entrée contrôlée par l’attaquant, et l’habitude de concaténer « parce que c’est un nombre » est précisément la faille. Paramètre par défaut, toujours.
Chapitre suivant : Command, NoSQL & LDAP — le même mécanisme appliqué au shell système, aux bases NoSQL et aux annuaires, avec leurs parades spécifiques.