Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1521 minDébutant

Injection SQL et protection des données

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • expliquer comment fonctionne une injection SQL ;
  • bloquer une injection avec des requêtes préparées ;
  • stocker un mot de passe haché ;
  • chiffrer une colonne sensible et respecter le RGPD.

4 exercices corrigés1 schéma

Une injection SQL glisse du code SQL dans une saisie qu'une application colle telle quelle dans sa requête. Ce chapitre montre comment elle fonctionne et comment s'en protéger avec des requêtes préparées, puis comment protéger les données elles-mêmes : mots de passe hachés, colonnes chiffrées, RGPD, audit par trigger et journaux du serveur. Les exemples s'appuient sur la base d'exemple parc.

L'injection SQL

Une page de l'intranet cherche les tickets par mot-clé. Le code PHP construit la requête en collant la saisie de l'utilisateur dans la chaîne SQL :

php
$q = $_GET['q'];
$sql = "SELECT id_ticket, titre FROM ticket WHERE titre LIKE '%" . $q . "%'";
$resultat = $pdo->query($sql);

Avec q = VPN, le serveur reçoit une requête normale. Un attaquant saisit plutôt :

text
x%' UNION SELECT id_employe, email FROM employe -- 

(avec une espace après les deux tirets). La chaîne envoyée au serveur devient :

sql
SELECT id_ticket, titre FROM ticket WHERE titre LIKE '%x%' UNION SELECT id_employe, email FROM employe -- %'

L'apostrophe saisie a fermé la chaîne, le UNION ajoute les emails de tous les employés au résultat, et -- transforme la fin de la requête d'origine en commentaire. La page affiche maintenant l'annuaire complet. Avec d'autres charges, l'attaquant contourne une authentification (' OR '1'='1), lit les tables de comptes ou, si le compte de l'application a trop de droits, modifie et supprime des données.

Les requêtes préparées

Une requête préparée envoie au serveur la structure de la requête, avec des marqueurs à la place des valeurs, puis les valeurs séparément. Le serveur analyse la requête avant de connaître les valeurs : une valeur ne peut jamais devenir du code SQL, quel que soit son contenu.

Concaténation : la saisie devient du codeApplicationPHPServeurMySQL… LIKE '%x%' UNION SELECT id_employe,email FROM employe -- Renvoie les emails des employésRequête préparée : la saisie reste une donnéeApplicationPHPServeurMySQL1. SELECT … WHERE titre LIKE ?2. valeur : "%x%' UNION SELECT … --%"Aucun titre ne contient ce texte : 0 ligne
Concaténation : la saisie devient du code. Requête préparée : la saisie reste une donnée

En PHP avec PDO (voir aussi le chapitre PDO et requêtes préparées du cours PHP et MySQL) :

php
$pdo = new PDO('mysql:host=10.0.1.10;dbname=parc;charset=utf8mb4', $utilisateur, $motDePasse, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,     // vraies requêtes préparées côté serveur
]);

$stmt = $pdo->prepare('SELECT id_ticket, titre FROM ticket WHERE titre LIKE :motif');
$stmt->execute(['motif' => '%' . $_GET['q'] . '%']);
$tickets = $stmt->fetchAll(PDO::FETCH_ASSOC);

En Node.js avec mysql2 :

js
import mysql from 'mysql2/promise';

const pool = mysql.createPool({ host: '10.0.1.10', user: 'app_parc', password: process.env.DB_PASSWORD, database: 'parc' });

const [tickets] = await pool.execute(
  'SELECT id_ticket, titre FROM ticket WHERE titre LIKE ?',
  [`%${q}%`]
);

En C# avec MySqlConnector :

csharp
await using var connexion = new MySqlConnection(chaineDeConnexion);
await connexion.OpenAsync();

await using var cmd = new MySqlCommand(
    "SELECT id_ticket, titre FROM ticket WHERE titre LIKE @motif", connexion);
cmd.Parameters.AddWithValue("@motif", $"%{q}%");

await using var lecteur = await cmd.ExecuteReaderAsync();
while (await lecteur.ReadAsync())
{
    Console.WriteLine($"{lecteur.GetInt32(0)} {lecteur.GetString(1)}");
}

Les % du LIKE font partie de la valeur : on les ajoute à la donnée, jamais dans la chaîne SQL autour du marqueur.

Un marqueur remplace une valeur, jamais un nom de table, de colonne ou un mot-clé. Pour un tri choisi par l'utilisateur, on vérifie la saisie contre une liste autorisée :

php
$colonnesTriables = ['date_ouverture', 'priorite', 'statut'];
$tri = in_array($_GET['tri'] ?? '', $colonnesTriables, true) ? $_GET['tri'] : 'date_ouverture';
$sens = ($_GET['sens'] ?? '') === 'asc' ? 'ASC' : 'DESC';
$stmt = $pdo->prepare("SELECT id_ticket, titre FROM ticket WHERE statut = ? ORDER BY $tri $sens");
$stmt->execute([$statut]);

$tri et $sens ne peuvent valoir qu'une des valeurs prévues par le code : leur concaténation est sans risque.

Les mots de passe

Une application qui gère ses propres comptes stocke les mots de passe des utilisateurs. Jamais en clair : une fuite de la base (une sauvegarde volée, une injection) exposerait des mots de passe que les gens réutilisent ailleurs. Jamais non plus avec MD5, SHA1 ou même SHA2 seuls : ces fonctions sont conçues pour être rapides, et une carte graphique en calcule des milliards par seconde.

On utilise une fonction de hachage de mot de passe : lente par construction, avec un sel aléatoire différent pour chaque compte, stocké dans le résultat. Les algorithmes recommandés sont Argon2id et bcrypt. Le hachage se fait dans l'application, pas dans la base : ainsi le mot de passe ne transite pas dans les requêtes ni dans les journaux du serveur.

php
// Inscription : stocker le haché (colonne VARCHAR(255))
$hache = password_hash($motDePasse, PASSWORD_DEFAULT);   // bcrypt aujourd'hui, coût 12 depuis PHP 8.4
$stmt = $pdo->prepare('UPDATE compte SET mot_de_passe = ? WHERE login = ?');
$stmt->execute([$hache, $login]);

// Connexion : vérifier
$stmt = $pdo->prepare('SELECT mot_de_passe FROM compte WHERE login = ?');
$stmt->execute([$login]);
$hacheStocke = $stmt->fetchColumn();
if ($hacheStocke !== false && password_verify($motDePasse, $hacheStocke)) {
    // connexion acceptée
}

password_hash() renvoie une chaîne qui contient l'algorithme, le coût, le sel et le haché, par exemple $2y$12$... pour bcrypt. PASSWORD_ARGON2ID choisit Argon2id quand PHP le propose. En Node.js, les paquets argon2 ou bcrypt jouent le même rôle ; en C#, BCrypt.Net-Next ou le PasswordHasher d'ASP.NET Core Identity. Le chapitre Stocker un mot de passe du cours Cryptographie explique pourquoi le sel et la lenteur arrêtent les attaques par dictionnaire.

Chiffrer une colonne sensible

Certaines données doivent être relues, mais protégées si la base fuit : un numéro de sécurité sociale, un IBAN, une donnée médicale. On les chiffre avec une clé que la base ne contient pas en clair.

Avec MySQL, AES_ENCRYPT et AES_DECRYPT chiffrent et déchiffrent. Le mode par défaut, aes-128-ecb, est faible : choisis un mode avec vecteur d'initialisation et stocke ce vecteur avec la donnée (les modes ECB, CBC et GCM sont expliqués dans le chapitre Chiffrement symétrique). Le résultat est binaire : la colonne est en VARBINARY ou en BLOB.

sql
ALTER TABLE employe
  ADD COLUMN iban_chiffre VARBINARY(64),
  ADD COLUMN iban_iv BINARY(16);

SET block_encryption_mode = 'aes-256-cbc';
SET @cle = SHA2('phrase secrète fournie par l''application', 256);
SET @iv  = RANDOM_BYTES(16);

UPDATE employe
SET iban_chiffre = AES_ENCRYPT('FR7630001007941234567890185', @cle, @iv),
    iban_iv = @iv
WHERE id_employe = 2;

SELECT nom, CAST(AES_DECRYPT(iban_chiffre, @cle, iban_iv) AS CHAR) AS iban
FROM employe
WHERE id_employe = 2;

La documentation de MySQL le rappelle : les clés passées en argument transitent en clair si la connexion n'est pas chiffrée, et apparaissent dans les journaux qui enregistrent les requêtes. En production, on préfère chiffrer dans l'application, avec une clé gardée hors de la base (un coffre de secrets, une variable d'environnement protégée).

Avec SQL Server, la démarche qui revient dans les sujets d'E7 SLAM s'appuie sur une hiérarchie de clés : une clé principale de la base protège un certificat, qui protège une clé symétrique.

sql
-- SQL Server (T-SQL)
CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'Mot-de-passe-de-la-cle-principale!';
CREATE CERTIFICATE cert_parc WITH SUBJECT = 'Protection des données du parc';
CREATE SYMMETRIC KEY cle_parc WITH ALGORITHM = AES_256 ENCRYPTION BY CERTIFICATE cert_parc;

-- 1. Ajouter la colonne chiffrée
ALTER TABLE employe ADD telephone_chiffre VARBINARY(256);
GO

-- 2. La remplir à partir de la colonne en clair
OPEN SYMMETRIC KEY cle_parc DECRYPTION BY CERTIFICATE cert_parc;
UPDATE employe SET telephone_chiffre = EncryptByKey(Key_GUID('cle_parc'), telephone);
CLOSE SYMMETRIC KEY cle_parc;

-- 3. Supprimer la colonne en clair, puis renommer la colonne chiffrée
ALTER TABLE employe DROP COLUMN telephone;
EXEC sp_rename 'employe.telephone_chiffre', 'telephone', 'COLUMN';
GO

-- Lecture : déchiffrer, puis reconvertir en texte
OPEN SYMMETRIC KEY cle_parc DECRYPTION BY CERTIFICATE cert_parc;
SELECT nom, CONVERT(VARCHAR(20), DecryptByKey(telephone)) AS telephone FROM employe;
CLOSE SYMMETRIC KEY cle_parc;

Retiens l'ordre en quatre temps : ajouter la colonne binaire, la remplir avec la valeur chiffrée, supprimer la colonne en clair, renommer. DecryptByKey renvoie du binaire : convertis-le dans le type d'origine. GO n'est pas une instruction SQL : c'est le séparateur de lots de SSMS et de sqlcmd. SQL Server compile tout un lot avant de l'exécuter ; sans GO après l'ALTER TABLE, l'UPDATE qui suit ferait référence à une colonne qui n'existe pas encore à la compilation (erreur 207, Invalid column name 'telephone_chiffre'). En T-SQL, sépare toujours par GO une instruction qui crée ou renomme une colonne et celles qui l'utilisent.

Le chiffrement d'une colonne ne remplace pas deux autres protections :

  • la connexion chiffrée (TLS) entre l'application et le serveur, pour que les données ne circulent pas en clair sur le réseau. MySQL 8 propose TLS par défaut ; ALTER USER 'app_parc'@'10.0.1.20' REQUIRE SSL; l'impose à un compte, et la variable require_secure_transport = ON l'impose à tous ;
  • le chiffrement des fichiers de données sur le disque (InnoDB sait chiffrer ses espaces de tables), contre le vol d'un disque ou d'une sauvegarde.

Données personnelles et RGPD

La base parc contient des données personnelles : noms, emails, téléphones, et l'historique des demandes de chaque salarié. Le RGPD (cours Droit informatique et RGPD) impose plusieurs principes, qui se traduisent directement en SQL :

  • Minimisation : ne stocker que ce qui est nécessaire à la finalité. L'assistance a besoin d'un nom et d'un moyen de contact, pas d'une date de naissance.
  • Accès limité : des vues et des droits par profil.
  • Durée de conservation : définir combien de temps on garde les tickets, puis purger ou anonymiser régulièrement.
  • Pseudonymisation : remplacer l'identité par un identifiant, la correspondance étant gardée à part, avec des droits plus stricts.
  • Droit à l'effacement : une personne peut demander la suppression de ses données. Les clés étrangères en RESTRICT empêchent de supprimer un employé qui a des tickets ; on anonymise alors sa ligne au lieu de la supprimer.
sql
-- Anonymiser une ancienne salariée en gardant l'historique de ses tickets (Léa Rousseau, ticket 12)
UPDATE employe
SET nom = 'Anonyme', prenom = CONCAT('n°', id_employe),
    email = CONCAT('anonyme', id_employe, '@sorbier.example'),
    telephone = NULL
WHERE id_employe = 13;

L'anonymisation ne s'arrête pas à la ligne de l'employé : les textes libres peuvent eux aussi contenir son nom, comme le titre du ticket 4, « Créer le compte de Paul Martin », ou un compte rendu d'intervention. Recherche-les et réécris-les aussi.

Tracer les modifications avec un trigger

Pour savoir qui a changé le statut d'un ticket et quand, on crée une table d'audit remplie automatiquement par un trigger (déclencheur), un programme stocké que le serveur exécute à chaque modification de la table. Le cours Procédures stockées et triggers détaille leur écriture.

sql
CREATE TABLE audit_ticket (
  id_audit       INT AUTO_INCREMENT PRIMARY KEY,
  id_ticket      INT          NOT NULL,          -- volontairement sans clé étrangère
  ancien_statut  VARCHAR(10)  NOT NULL,
  nouveau_statut VARCHAR(10)  NOT NULL,
  modifie_par    VARCHAR(300) NOT NULL,
  modifie_le     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
);

DELIMITER //
CREATE TRIGGER trg_ticket_audit_statut
AFTER UPDATE ON ticket
FOR EACH ROW
BEGIN
  IF OLD.statut <> NEW.statut THEN
    INSERT INTO audit_ticket (id_ticket, ancien_statut, nouveau_statut, modifie_par)
    VALUES (OLD.id_ticket, OLD.statut, NEW.statut, USER());
  END IF;
END //
DELIMITER ;
  • OLD et NEW désignent la ligne avant et après la modification.
  • DELIMITER // change le séparateur d'instructions du client mysql, pour que les ; du corps du trigger ne terminent pas le CREATE TRIGGER. Les clients graphiques n'en ont souvent pas besoin.
  • USER() renvoie le compte qui a ouvert la connexion. CURRENT_USER() renverrait ici le créateur du trigger : un trigger s'exécute avec les droits de son créateur.
  • La table d'audit n'a pas de clé étrangère vers ticket : la trace doit survivre à la suppression du ticket.

Les comptes applicatifs ne doivent avoir que INSERT sur une table d'audit, voire aucun droit (le trigger s'en charge) : une trace que l'on peut modifier ne prouve rien.

Les journaux du serveur

JournalContenuActivation
Journal des erreursDémarrages, arrêts, erreurs graves, connexions refusées selon la configurationToujours actif (variable log_error)
Journal généralToutes les requêtes reçues, avec le compteSET GLOBAL general_log = 'ON';, très volumineux, à activer le temps d'un diagnostic
Journal des requêtes lentesLes requêtes plus longues que long_query_time secondes (10 par défaut)SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;
Journal binaireToutes les modifications de données, pour la réplication et la restaurationActif par défaut depuis MySQL 8.0 (restauration à un instant donné)

L'outil mysqldumpslow résume le journal des requêtes lentes en regroupant les requêtes semblables : c'est le point de départ pour savoir quoi indexer. Le journal général, lui, contient les données des requêtes : protège-le comme la base.

Exercices

Exercice 1 sur 4 Trouver et corriger l'injection Réussi À revoir

Ce code d'authentification contient une injection SQL. Montre une saisie qui permet de se connecter sans connaître le mot de passe, puis réécris le code correctement. Les mots de passe sont stockés hachés avec password_hash().

php
$sql = "SELECT id_compte FROM compte
        WHERE login = '" . $_POST['login'] . "'
        AND mot_de_passe = '" . md5($_POST['mdp']) . "'";
$compte = $pdo->query($sql)->fetch();
if ($compte) { $_SESSION['id'] = $compte['id_compte']; }
Voir la réponseMasquer la réponse de l'exercice 1

Avec le login admin' -- (espace finale comprise), la requête devient ... WHERE login = 'admin' -- ' AND mot_de_passe = '...' : la vérification du mot de passe est en commentaire, la connexion est acceptée pour le compte admin.

Le code a un second défaut : md5(), un hachage rapide et sans sel. La correction utilise une requête préparée et password_verify() :

php
$stmt = $pdo->prepare('SELECT id_compte, mot_de_passe FROM compte WHERE login = ?');
$stmt->execute([$_POST['login']]);
$compte = $stmt->fetch(PDO::FETCH_ASSOC);

if ($compte && password_verify($_POST['mdp'], $compte['mot_de_passe'])) {
    session_regenerate_id(true);
    $_SESSION['id'] = $compte['id_compte'];
}

On ne compare plus le mot de passe dans le WHERE : on récupère le haché du login, et password_verify() le vérifie. session_regenerate_id() évite la fixation de session.

Tu avais trouvé ?

Exercice 2 sur 4 Vrai ou faux Réussi À revoir

  1. Une requête préparée protège aussi un nom de colonne passé en paramètre.
  2. SHA2(mot_de_passe, 256) est une bonne façon de stocker un mot de passe, car SHA-256 n'est pas cassé.
  3. Échapper les apostrophes avec addslashes() suffit à empêcher l'injection.
  4. Un compte applicatif sans privilège DELETE limite les dégâts d'une injection.
Voir la réponseMasquer la réponse de l'exercice 2
  1. Faux. Un marqueur ne remplace qu'une valeur. Un nom de colonne ou un sens de tri se vérifie contre une liste autorisée.
  2. Faux. SHA-256 est rapide et, utilisé seul, sans sel : on teste des milliards de mots de passe par seconde et deux comptes au même mot de passe ont le même haché. Utilise Argon2id ou bcrypt, dans l'application.
  3. Faux. Les contournements existent (jeux de caractères multioctets, valeurs numériques non entourées d'apostrophes). Seule la requête préparée sépare réellement le code des données.
  4. Vrai. Le moindre privilège ne supprime pas la faille, mais il réduit ce qu'un attaquant peut en faire. Les deux protections se complètent.

Tu avais trouvé ?

Exercice 3 sur 4 Chiffrer une colonne existante Réussi À revoir

Sur SQL Server, la table patient contient une colonne groupe_sanguin en clair, de type VARCHAR(3). La clé symétrique cle_sante, protégée par le certificat cert_sante, existe. Écris, dans l'ordre, les instructions qui remplacent la colonne en clair par une colonne chiffrée du même nom.

Voir la réponseMasquer la réponse de l'exercice 3
sql
ALTER TABLE patient ADD groupe_sanguin_chiffre VARBINARY(128);
GO

OPEN SYMMETRIC KEY cle_sante DECRYPTION BY CERTIFICATE cert_sante;
UPDATE patient SET groupe_sanguin_chiffre = EncryptByKey(Key_GUID('cle_sante'), groupe_sanguin);
CLOSE SYMMETRIC KEY cle_sante;

ALTER TABLE patient DROP COLUMN groupe_sanguin;
EXEC sp_rename 'patient.groupe_sanguin_chiffre', 'groupe_sanguin', 'COLUMN';
GO

Les GO séparent les lots : sans eux, SQL Server refuserait l'UPDATE, qui cite une colonne pas encore créée au moment où il compile le lot. Vérifie que toutes les lignes ont une valeur chiffrée avant de supprimer la colonne en clair. Après la migration, les requêtes de lecture doivent ouvrir la clé et utiliser CONVERT(VARCHAR(3), DecryptByKey(groupe_sanguin)).

Tu avais trouvé ?

Exercice 4 sur 4 Auditer les suppressions Réussi À revoir

Complète le trigger pour qu'il enregistre dans audit_suppression (id_audit, table_cible, id_ligne, supprime_par, supprime_le) chaque intervention supprimée.

sql
DELIMITER //
CREATE TRIGGER trg_intervention_audit_suppression
________ ________ ON intervention
FOR EACH ROW
BEGIN
  INSERT INTO audit_suppression (table_cible, id_ligne, supprime_par, supprime_le)
  VALUES ('intervention', ________, ________, NOW());
END //
DELIMITER ;
Voir la réponseMasquer la réponse de l'exercice 4
sql
DELIMITER //
CREATE TRIGGER trg_intervention_audit_suppression
AFTER DELETE ON intervention
FOR EACH ROW
BEGIN
  INSERT INTO audit_suppression (table_cible, id_ligne, supprime_par, supprime_le)
  VALUES ('intervention', OLD.id_intervention, USER(), NOW());
END //
DELIMITER ;

Dans un trigger DELETE, seule la ligne OLD existe. AFTER garantit qu'on ne trace que les suppressions réellement effectuées. Attention : une intervention supprimée par la cascade d'un ticket supprimé ne déclenche pas ce trigger, car MySQL n'exécute pas les triggers lors des actions des clés étrangères. Pour tout tracer, ajoute aussi un trigger DELETE sur ticket.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Procédures stockées et triggers (aperçu)Revoir le sommaire du cours