Aller au contenu principal

Injection SQL

  • Comprendre comment une application vulnérable peut être attaquée via une injection SQL.
  • Comprendre que hacher un mot de passe ne suffit pas à empêcher une injection SQL.
  • Apprendre à sécuriser une requête SQL en utilisant PDO avec une requête préparée (prepare() + bindParam()).
attention

Si vos requêtes SQL ne sont pas correctement sécurisées, un attaquant peut injecter du code SQL malveillant pour accéder, modifier voire supprimer votre base de données.


1. Création de la base de données​

Créez une base de données site_marchand et une table comptes, qui simule les comptes clients d'un site marchand fictif (identifiant, mot de passe, et numéro de carte bancaire associé).

CREATE DATABASE site_marchand;
USE site_marchand;

CREATE TABLE comptes (
id INT PRIMARY KEY AUTO_INCREMENT,
identifiant VARCHAR(30) NOT NULL UNIQUE,
motdepasse VARCHAR(255) NOT NULL,
typecarte VARCHAR(30) NOT NULL,
numerocarte VARCHAR(30) NOT NULL
);

INSERT INTO comptes (identifiant, motdepasse, typecarte, numerocarte) VALUES
('user1', 'abc', 'VISA', '987654321987'),
('user2', 'password', 'CB', '096743218765'),
('user3', 'techonthenet', 'CB', '637698341021'),
('user4', 'techonthenet', 'VISA', '990176348956'),
('user5', 'password', 'VISA', '985637980662');
info

Pour ce TP, les mots de passe sont volontairement enregistrés en clair dans un premier temps (colonne motdepasse) : nous les hacherons à l'étape 6. Les numéros de carte sont fictifs, mais retenez qu'en conditions réelles, un site marchand ne doit jamais stocker de vrais numéros de carte bancaire non chiffrés dans sa base (norme PCI-DSS).


2. Implémentation HTML​

Créez le fichier index.html, le formulaire de connexion à l'espace client :

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Se connecter - Site marchand</title>
</head>
<body>
<h1>Connexion à votre espace client</h1>
<form action="afficher_numero_carte.php" method="get">
<label for="identifiant">Identifiant :</label>
<input type="text" id="identifiant" name="identifiant"><br/>
<label for="motdepasse">Mot de passe :</label>
<input type="password" id="motdepasse" name="motdepasse"><br/>
<input type="submit" value="Se connecter">
</form>
</body>
</html>

3. Implémentation PHP (sans hachage du mot de passe)​

Créez le fichier afficher_numero_carte.php, qui vérifie l'identifiant et le mot de passe, puis affiche le numéro de carte du client connecté :

<?php
$dsn = "mysql:host=127.0.0.1;port=3306;dbname=site_marchand;charset=utf8mb4";

try {
$pdo = new PDO($dsn, "root", "root", [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
} catch (PDOException $e) {
die("Erreur de connexion : " . $e->getMessage());
}

$identifiant = $_GET['identifiant'] ?? '';
$motdepasse = $_GET['motdepasse'] ?? '';

$requeteSQL = "SELECT numerocarte FROM comptes WHERE identifiant = '$identifiant' AND motdepasse = '$motdepasse'";
print("Requête SQL exécutée : $requeteSQL<br>");

$resultat = $pdo->query($requeteSQL)->fetch();

if ($resultat) {
echo "Numéro de carte : " . $resultat['numerocarte'];
} else {
echo "Identifiant ou mot de passe incorrect.";
}
info

Afficher la requête SQL exécutée (print($requeteSQL)) est utile ici pour comprendre l'attaque pendant le TP. Sur un vrai site, ne jamais afficher une requête SQL à l'utilisateur : elle révèle la structure de la base de données à un attaquant.


4. Démarrer le serveur Web​

Démarrez le serveur Web intégré à PHP, dans le dossier contenant les deux fichiers :

php -S localhost:8000

Ouvrez ensuite http://localhost:8000/ dans votre navigateur.


5. Tester la connexion de l'utilisateur​

  1. Bon mot de passe : user1 / abc → le numéro de carte s'affiche.
  2. Mauvais mot de passe : user1 / xyz → "Identifiant ou mot de passe incorrect."
  3. Injection SQL : identifiant = ' OR '1'='1' -- , mot de passe : (vide)
attention

Avec l'injection, un numéro de carte s'affiche à nouveau, alors qu'aucun mot de passe correct n'a été saisi ! Le -- (suivi d'un espace) transforme la fin de la requête en commentaire SQL, et '1'='1' est toujours vrai : la clause WHERE est neutralisée, la première ligne de la table comptes est retournée.

Le caractère # fonctionne aussi

Sous MariaDB/MySQL, le caractère # est aussi accepté comme début de commentaire (jusqu'à la fin de la ligne). L'attaquant peut donc saisir ' OR '1'='1' # à la place de ' OR '1'='1' -- : le résultat est identique. Avec #, aucune espace n'est nécessaire derrière le symbole, alors que -- doit être suivi d'une espace pour être reconnu comme un commentaire.


6. Hashage du mot de passe​

Stocker les mots de passe en clair est dangereux : en cas de fuite de la base, tous les mots de passe des clients seraient directement exploitables. Remplacez-les par leur empreinte, calculée ici avec la fonction SQL PASSWORD() :

UPDATE comptes SET motdepasse = PASSWORD('abc') WHERE identifiant = 'user1';
UPDATE comptes SET motdepasse = PASSWORD('password') WHERE identifiant IN ('user2', 'user5');
UPDATE comptes SET motdepasse = PASSWORD('techonthenet') WHERE identifiant IN ('user3', 'user4');

Modifiez ensuite afficher_numero_carte.php pour comparer le mot de passe saisi haché de la même façon, en enveloppant $motdepasse avec PASSWORD() dans la requête :

$requeteSQL = "SELECT numerocarte FROM comptes WHERE identifiant = '$identifiant' AND motdepasse = PASSWORD('$motdepasse')";
info

PASSWORD() est une fonction historique de MySQL/MariaDB, encore disponible sous MariaDB. Pour une application PHP moderne, préférez password_hash() et password_verify() avec l'algorithme bcrypt, plus robuste. PASSWORD() est utilisée ici uniquement parce qu'elle se manipule directement en SQL, ce qui simplifie ce TP.


7. Tester la connexion de l'utilisateur​

Rejouez exactement les mêmes tests qu'à l'étape 5 :

  1. Bon mot de passe : user1 / abc → fonctionne toujours (PASSWORD('abc') reproduit le même hachage que celui stocké).
  2. Mauvais mot de passe : user1 / xyz → échoue toujours.
  3. Injection SQL : identifiant = ' OR '1'='1' -- , mot de passe : (vide)
attention

L'injection fonctionne toujours ! Le hachage du mot de passe ne protège que la confidentialité du mot de passe stocké — il ne change rien à la façon dont $identifiant est concaténé dans la requête. Le -- continue de supprimer la vérification du mot de passe (haché ou non) : hacher un mot de passe n'empêche pas une injection SQL.


8. Sécurisation du code PHP avec une requête préparée paramétrée​

Modifiez afficher_numero_carte.php pour utiliser une requête préparée, avec bindParam() pour chaque paramètre :

<?php
$dsn = "mysql:host=127.0.0.1;port=3306;dbname=site_marchand;charset=utf8mb4";

try {
$pdo = new PDO($dsn, "root", "root", [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
} catch (PDOException $e) {
die("Erreur de connexion : " . $e->getMessage());
}

$identifiant = $_GET['identifiant'] ?? '';
$motdepasse = $_GET['motdepasse'] ?? '';

$requeteSQL = "SELECT numerocarte FROM comptes WHERE identifiant = :identifiant AND motdepasse = PASSWORD(:motdepasse)";
$stmt = $pdo->prepare($requeteSQL);
$stmt->bindParam(':identifiant', $identifiant);
$stmt->bindParam(':motdepasse', $motdepasse);
$stmt->execute();

$resultat = $stmt->fetch();

if ($resultat) {
echo "Numéro de carte : " . $resultat['numerocarte'];
} else {
echo "Identifiant ou mot de passe incorrect.";
}
info

bindParam(':identifiant', $identifiant) lie la variable $identifiant au paramètre nommé :identifiant : PDO lira sa valeur au moment de execute().

La valeur est transmise séparément de la requête SQL, jamais comme du code SQL exécutable.


9. Tester la connexion de l'utilisateur​

Rejouez une dernière fois exactement les mêmes tests :

  1. Bon mot de passe : user1 / abc → fonctionne toujours.
  2. Mauvais mot de passe : user1 / xyz → échoue toujours.
  3. Injection SQL : identifiant = ' OR '1'='1' -- , mot de passe : (vide)

Résultat attendu : cette fois, l'injection échoue : "Identifiant ou mot de passe incorrect."


10. Explication de la sécurisation​

  • prepare() envoie d'abord la requête SQL au serveur, sans aucune valeur : sa structure (les clauses WHERE, AND...) est donc figée avant même que $identifiant ou $motdepasse ne soient connus.
  • bindParam() associe ensuite chaque valeur à son paramètre nommé (:identifiant, :motdepasse). Le serveur les traite comme de simples données à comparer, jamais comme un morceau de requête SQL.
  • Résultat : même si $identifiant contient ' OR '1'='1' -- , cette chaîne est comparée telle quelle à la colonne identifiant — elle ne correspond à aucun compte, et ne peut plus modifier la structure de la requête.
attention

Aux étapes 6 et 7, le hachage protégeait la confidentialité des mots de passe stockés (utile en cas de fuite de la base), mais ne protégeait pas du tout contre l'injection SQL : c'est bien la requête préparée, et elle seule, qui corrige la faille. Les deux protections sont complémentaires, ni l'une ni l'autre ne remplace l'autre.


Notions théoriques​

L'injection SQL est l'une des attaques les plus courantes et dangereuses en cybersécurité.

Elle permet à un attaquant d'exécuter des requêtes SQL malveillantes sur une base de données en exploitant une faille dans un formulaire ou une URL mal sécurisée.

Fonctionnement de l'injection SQL​

Lorsqu'un site Web interagit avec une base de données, il utilise des requêtes SQL pour récupérer, insérer, modifier ou supprimer des données (CRUD).

Exemple de vulnérabilité​

Reprenons le formulaire de connexion du TP :

SELECT numerocarte FROM comptes
WHERE identifiant = '$identifiant'
AND motdepasse = '$motdepasse';

Si l'application insère directement les valeurs de l'utilisateur sans vérification, un attaquant peut entrer :

  • Identifiant : user1' --
  • Mot de passe : (vide)

Ce qui donnera la requête suivante :

SELECT numerocarte FROM comptes WHERE identifiant = 'user1' -- ' AND motdepasse = '';

Explication :

  • Le -- (suivi d'une espace) transforme le reste de la requête en commentaire, permettant à l'attaquant de se connecter sans mot de passe, dès qu'il connaît (ou devine) un identifiant valide.

  • Sous MariaDB/MySQL, le caractère # est aussi accepté comme début de commentaire (jusqu'à la fin de la ligne). L'attaquant peut donc entrer user1' # et obtenir le même résultat, sans avoir besoin de l'espace qui doit suivre -- :

    SELECT numerocarte FROM comptes WHERE identifiant = 'user1' #' AND motdepasse = '';

Résultat​

L'attaquant accède au compte de user1, sans connaître son mot de passe.

Utiliser ' OR 1=1 --`

Un attaquant peut aussi essayer de contourner l'authentification sans connaître aucun identifiant valide, en entrant :

  • Identifiant : ' OR 1=1 --
  • Mot de passe : (vide)

Cette entrée modifie la requête SQL comme suit :

SELECT numerocarte FROM comptes WHERE identifiant = '' OR 1=1 -- ' AND motdepasse = '';

Explication :

  • OR 1=1 est toujours vrai, donc la condition est validée pour tous les comptes.
  • -- (suivi d'une espace) transforme le reste en commentaire, supprimant la vérification du mot de passe.
  • Sous MariaDB/MySQL, ' OR 1=1 # fonctionne aussi, car # est également accepté comme commentaire.

Résultat​

L'attaquant est connecté en tant que premier compte de la table (souvent le plus ancien, parfois un compte administrateur).

Le hachage ne protège pas contre l'injection​

Une confusion fréquente est de croire que hacher les mots de passe (étapes 6 et 7 du TP) protège aussi contre l'injection SQL. Ce n'est pas le cas : l'injection agit sur la colonne identifiant et sur la structure même de la requête (via -- ou OR), pas sur la comparaison du mot de passe. Hacher protège une donnée stockée, une requête préparée protège une requête exécutée : ce sont deux défenses différentes, contre deux risques différents.

Comment se protéger ?​

  • Utilisation des requêtes préparées (prepare() + bindParam()) pour éviter l'injection.
  • Hachage des mots de passe (PASSWORD() en SQL, ou mieux password_hash()/password_verify() en PHP) pour protéger leur confidentialité en cas de fuite de données.
  • Validation des entrées : vérifier et filtrer le format des données saisies (ex : filter_var() pour un email), en complément, jamais en remplacement, des requêtes préparées.
  • Utilisation de privilèges limités : ne pas donner trop de droits aux utilisateurs de la base de données.
  • Désactivation des erreurs SQL visibles : ne pas afficher les messages d'erreur SQL à l'utilisateur.

Simulation d'une attaque​

Source : https://guerinludovic.forge.apps.education.fr/simulateur-injection-sql/


Test de mémorisation/compréhension​


Quelle est la principale cause d'une injection SQL ?


Dans le TP, quelle table stocke les comptes clients du site marchand ?


Dans le TP, quelle séquence de caractères met en commentaire la fin de la requête SQL ?


Dans le TP, que se passe-t-il en saisissant l'identifiant `' OR '1'='1' -- ` avec un mot de passe vide, avant toute sécurisation ?


Après l'étape 6 (hachage du mot de passe avec PASSWORD()), l'injection de l'étape 5 fonctionne-t-elle encore ?


Pourquoi le hachage du mot de passe ne suffit-il pas à empêcher l'injection SQL du TP ?


Quelle est la meilleure méthode pour empêcher réellement l'injection SQL ?


Dans le TP, quelle méthode PDO est utilisée pour lier chaque valeur à son paramètre nommé ?


Que fait exactement prepare() avant que bindParam() et execute() ne soient appelés ?


Pourquoi une requête SQL contenant `OR 1=1` est-elle problématique ?


Pourquoi ne faut-il pas afficher les erreurs ou les requêtes SQL à l'utilisateur final, en production ?


Dans le TP, pourquoi ne faut-il jamais stocker de vrais numéros de carte bancaire non protégés dans une base de données ?


Quelle fonction PHP est recommandée, en remplacement de PASSWORD() en SQL, pour hacher un mot de passe côté application ?


Le hachage des mots de passe et les requêtes préparées protègent-ils contre le même risque ?



Résumé​

Schéma


Bonnes pratiques​

  • Utiliser des requêtes préparées (prepare() + bindParam()) pour éviter l'injection SQL.
  • Hacher les mots de passe (password_hash()/password_verify() en PHP), en gardant à l'esprit que cela ne remplace jamais la requête préparée.
  • Valider les entrées : vérifier et filtrer les données saisies par l'utilisateur, en complément.
  • Utiliser des privilèges limités : ne pas donner trop de droits aux utilisateurs de la base de données.
  • Désactiver les erreurs SQL visibles : ne pas afficher les messages ni les requêtes SQL à l'utilisateur final.
attention

htmlspecialchars() protège contre les attaques XSS lors de l'affichage, pas contre les injections SQL. Ne l'utilisez jamais comme substitut aux requêtes préparées.


Ce que fait Symfony​

Avec Symfony, vous n'écrivez presque jamais de SQL à la main : Doctrine, l'ORM utilisé par Symfony, génère des requêtes préparées et transmet toujours les valeurs séparément de la requête, exactement comme prepare() + bindParam() dans ce TP.

// Doctrine prépare la requête et lie la valeur automatiquement
$compte = $comptesRepository->findOneBy(['identifiant' => $identifiant]);
attention

La protection disparaît si vous concaténez vous-même une variable dans une requête DQL ou SQL native. Utilisez toujours des paramètres (setParameter()).