Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 719 minIntermédiaire

Injections (A05) : SQL, NoSQL et commandes système

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

À la fin de ce chapitre, tu sauras

  • expliquer le mécanisme commun à toutes les injections ;
  • écrire une requête préparée en PHP, en JavaScript et en Java ;
  • traiter les cas qu'un marqueur ne couvre pas ;
  • bloquer une injection NoSQL ou de commandes ;
  • valider les entrées côté serveur par liste blanche.

4 exercices corrigés1 schéma

Une injection se produit quand une donnée fournie par l'utilisateur est insérée dans une commande destinée à un interpréteur (SGBD, base NoSQL, shell, navigateur) et qu'elle en change le sens : la donnée devient du code. C'est la catégorie A05:2025 de l'OWASP Top 10, celle qui compte le plus de vulnérabilités publiées. Ce chapitre présente l'injection SQL et sa parade, les cas que la requête préparée ne couvre pas, l'injection NoSQL, l'injection de commandes et la validation des entrées. Le XSS, autre forme d'injection, a son propre chapitre.

C'est quoi une injection ?

Une application construit souvent une commande en assemblant un texte fixe, écrit par le développeur, et une donnée variable, fournie par l'utilisateur. Tant que la donnée reste une donnée, tout va bien. Si elle contient des caractères qui ont un sens pour l'interpréteur (une apostrophe en SQL, un ; dans un shell, un < en HTML, un $ en MongoDB), elle peut fermer la partie « donnée » et ajouter ses propres instructions.

InterpréteurCaractères dangereux typiquesNom de l'injectionCWE
SGBD relationnel', --, ;Injection SQLCWE-89
Base NoSQL (MongoDB)objets { "$ne": … }, $whereInjection NoSQLCWE-943
Shell du système;, &&, |, `, $( )Injection de commandesCWE-78
Navigateur (HTML, JavaScript)<, >, ", 'Cross-site scripting (XSS)CWE-79
Annuaire LDAP*, (, )Injection LDAPCWE-90
Moteur de gabarits{{ }}, ${ }Injection de gabarit côté serveur (SSTI)CWE-1336

La parade de fond est toujours la même : séparer les données du code. On utilise une interface qui transmet la donnée à part, sans jamais la mélanger au texte de la commande.

L'injection SQL en bref

La page de connexion de la première version d'AppliFrais, en PHP, construit sa requête ainsi :

php
// VULNÉRABLE : la saisie est collée dans la chaîne SQL
$sql = "SELECT id, role FROM utilisateur WHERE login = '" . $_POST['login'] . "' AND mdp_hash = '" . $empreinte . "'";
$utilisateur = $pdo->query($sql)->fetch();

Un attaquant saisit comme identifiant admin' -- (avec une espace après les deux tirets). La requête reçue par MySQL devient :

sql
SELECT id, role FROM utilisateur WHERE login = 'admin' -- ' AND mdp_hash = '…'

L'apostrophe saisie ferme la chaîne, -- transforme la suite en commentaire : la vérification du mot de passe disparaît. L'attaquant est connecté comme administrateur. Avec d'autres charges, il lit toute la base (UNION SELECT), devine des données caractère par caractère quand rien ne s'affiche (injection « à l'aveugle »), ou modifie et supprime des données si le compte de l'application en a le droit. Des outils comme sqlmap automatisent tout cela.

L'attaque de MOVEit Transfer en 2023, qui a touché plus de 2 000 organisations, reposait sur une injection SQL. Le chapitre Injection SQL et protection des données du cours SQL détaille l'attaque pas à pas sur une base d'exemple.

Concaténation : vulnérablesaisie de l'attaquant (identifiant)admin' -- collée dans le SQLRequête reçue par MySQLSELECT id, role FROM utilisateurWHERE login = 'admin'-- ' AND mdp_hash = '…'← ignoréLe mot de passe n'est jamais vérifié :l'attaquant est connecté comme adminRequête préparée : sûre1. La structure, avec des ?SELECT id, role FROM utilisateurWHERE login = ? AND mdp_hash = ?MySQL fige la structure2. Les valeurs, envoyées à partlogin→ "admin' -- "mdp_hash→ "…"La saisie reste une donnée : aucunlogin « admin' -- », aucune ligne
Concaténée, la saisie devient du code ; préparée, elle reste une valeur

La parade, les requêtes préparées

Une requête préparée (ou paramétrée) envoie d'abord au SGBD la structure de la requête, avec des marqueurs (? ou :nom) à la place des valeurs ; les valeurs suivent séparément. Le SGBD a déjà analysé la requête quand il reçoit les valeurs : elles ne peuvent plus devenir du code, quel que soit leur contenu. L'identifiant admin' -- est cherché tel quel, apostrophe comprise, et ne correspond à aucun compte.

En PHP avec PDO :

php
$pdo = new PDO('mysql:host=10.0.1.10;dbname=gsb_frais;charset=utf8mb4', $utilisateurBdd, $motDePasseBdd, [
    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, role, mdp_hash FROM utilisateur WHERE login = :login AND actif = 1');
$stmt->execute(['login' => $_POST['login']]);
$utilisateur = $stmt->fetch(PDO::FETCH_ASSOC);

if ($utilisateur && password_verify($_POST['mdp'], $utilisateur['mdp_hash'])) {
    // connexion réussie
}

Le mot de passe n'est plus comparé en SQL : on récupère l'empreinte et on la vérifie avec password_verify (chapitre 6).

En Node.js avec mysql2 :

js
const [lignes] = await pool.execute(
  'SELECT id, role, mdp_hash FROM utilisateur WHERE login = ? AND actif = 1',
  [req.body.login]
);

execute utilise une vraie requête préparée ; query avec des ? échappe les valeurs côté client, ce qui protège aussi, mais execute est le réflexe à prendre.

Ces deux versions sont construites pas à pas, connexion et gestion des erreurs comprises, dans les chapitres PDO et requêtes préparées du cours PHP et Node.js et MySQL avec mysql2 du cours Node.js.

En Java avec JDBC, le langage de plusieurs sujets d'E7 :

java
String sql = "SELECT id, role, mdp_hash FROM utilisateur WHERE login = ? AND actif = 1";
try (PreparedStatement ps = connexion.prepareStatement(sql)) {
    ps.setString(1, login);
    try (ResultSet rs = ps.executeQuery()) {
        if (rs.next()) { /* vérifier l'empreinte */ }
    }
}

En C# avec MySqlConnector : cmd.Parameters.AddWithValue("@login", login) sur une requête qui contient @login.

Les cas que la requête préparée ne couvre pas

Un marqueur ne peut remplacer qu'une valeur. Il ne peut pas remplacer un nom de colonne, un nom de table, un mot-clé (ASC, DESC) ou une liste de longueur variable. C'est là que les injections survivent dans du code par ailleurs propre.

Le tri choisi par l'utilisateur. ORDER BY ? ne fonctionne pas comme on l'espère : la valeur est traitée comme une constante, et le tri est ignoré. La tentation est alors de concaténer la colonne. La parade est une liste blanche :

js
const colonnesTriables = { date: 'date_frais', montant: 'montant', libelle: 'libelle' };
const colonne = colonnesTriables[req.query.tri] ?? 'date_frais';   // valeur inconnue : tri par défaut
const sens = req.query.sens === 'asc' ? 'ASC' : 'DESC';
const sql = `SELECT * FROM ligne_frais WHERE id_fiche = ? ORDER BY ${colonne} ${sens}`;
await pool.execute(sql, [idFiche]);

Seules des chaînes écrites par le développeur entrent dans la requête.

Une liste de valeurs (IN). On génère autant de marqueurs que de valeurs :

js
const ids = req.body.ids.map(Number).filter(Number.isInteger);
if (ids.length === 0) return res.status(400).end();
const marqueurs = ids.map(() => '?').join(', ');
await pool.execute(`UPDATE fiche_frais SET etat = 'VA' WHERE id IN (${marqueurs})`, ids);

Les procédures stockées qui construisent du SQL dynamique. Une procédure n'est pas sûre par nature : si elle concatène son paramètre dans une requête qu'elle exécute ensuite (PREPARE … FROM CONCAT(…) en MySQL, EXEC(@sql) en SQL Server), l'injection se produit dans la base.

Les ORM utilisés de travers. Un ORM (Sequelize, Prisma, Doctrine, Eloquent, Entity Framework) prépare ses requêtes, mais tous offrent une porte vers le SQL brut : sequelize.query(), $queryRawUnsafe() de Prisma, DB::raw() de Laravel. Concaténer une saisie dans ces fonctions réintroduit l'injection.

LIKE et ses jokers. Avec WHERE libelle LIKE ? et la valeur %, l'utilisateur obtient toutes les lignes. Ce n'est pas une injection de code, mais si la recherche ne doit porter que sur ses propres lignes, la condition sur le propriétaire doit toujours être présente.

L'injection NoSQL

Les bases NoSQL ne parlent pas SQL, mais elles ont leurs propres opérateurs. Avec MongoDB, une requête est un objet JavaScript, et certains champs commencent par $ ($ne : différent de, $gt : supérieur à, $regex, $where). La version MongoDB d'AppliFrais vérifie la connexion ainsi :

js
// VULNÉRABLE : le corps JSON est passé tel quel au filtre
const utilisateur = await Utilisateur.findOne({ login: req.body.login, mdp: req.body.mdp });

L'attaquant n'envoie pas des chaînes, mais des objets :

json
{ "login": "admin", "mdp": { "$ne": null } }

Le filtre devient « login égal à admin et mdp différent de null » : vrai pour le compte admin. L'attaquant est connecté sans mot de passe. Avec { "login": { "$regex": "^a" } }, il énumère les comptes.

Les parades :

  1. Vérifier le type de chaque champ avant de construire le filtre : un login et un mot de passe sont des chaînes, pas des objets. Un schéma de validation (voir plus bas) le fait systématiquement.
  2. Activer l'assainissement des filtres de Mongoose : mongoose.set('sanitizeFilter', true) neutralise les objets qui commencent par $ dans les filtres.
  3. Ne jamais comparer un mot de passe dans la requête : on récupère le compte par son login, puis on vérifie l'empreinte avec argon2.verify. Le mot de passe n'entre plus dans le filtre.
  4. Bannir $where et toute évaluation de JavaScript côté base avec une saisie.
js
const { login, mdp } = req.body;
if (typeof login !== 'string' || typeof mdp !== 'string') {
  return res.status(400).json({ erreur: 'Requête invalide' });
}
const utilisateur = await Utilisateur.findOne({ login });
const valide = utilisateur && await argon2.verify(utilisateur.mdpHash, mdp);

Le cours MongoDB et API Node.js présente Mongoose et ses validations et détaille l'injection NoSQL et ses parades.

L'injection de commandes système

AppliFrais génère un PDF récapitulatif de chaque fiche en appelant un outil en ligne de commande :

js
// VULNÉRABLE : la chaîne est interprétée par un shell
import { exec } from 'node:child_process';
exec(`wkhtmltopdf https://frais.gsb.fr/fiches/${req.query.id} /tmp/fiche-${req.query.id}.pdf`);

Avec id=12;rm -rf /srv/appli-frais, le shell exécute deux commandes : la génération, puis la suppression. Les caractères ;, &&, |, ` et $( ) permettent d'enchaîner des commandes. L'attaquant exécute ce qu'il veut avec les droits de l'application.

Les parades, par ordre de préférence :

  1. Ne pas appeler de commande système : une bibliothèque du langage fait souvent le travail (une bibliothèque de génération de PDF, plutôt qu'un outil externe).
  2. Appeler le programme sans shell, avec des arguments séparés : execFile (ou spawn) passe chaque argument tel quel au programme, sans interprétation des caractères spéciaux.
  3. Valider strictement l'entrée : ici, un identifiant est un entier.
js
import { execFile } from 'node:child_process';

const id = Number.parseInt(req.query.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).end();
execFile('wkhtmltopdf', [`https://frais.gsb.fr/fiches/${id}`, `/tmp/fiche-${id}.pdf`], (erreur) => { /* … */ });

En PHP, on évite exec, system, shell_exec, passthru et les backticks avec une saisie ; si c'est indispensable, chaque argument passe par escapeshellarg().

Les autres injections

  • Injection LDAP : une saisie insérée dans un filtre d'annuaire ((uid= + saisie + )) ; avec *)(uid=*, le filtre renvoie tous les comptes. Parade : échapper avec la fonction prévue (ldap_escape en PHP).
  • Injection de gabarit côté serveur (SSTI) : une saisie utilisée comme modèle par un moteur de gabarits (Twig, Jinja, Handlebars) au lieu d'être passée comme variable. {{ 7*7 }} affiché 49 trahit la faille, qui peut mener à l'exécution de code. Parade : ne jamais construire un gabarit à partir d'une saisie.
  • Injection dans les journaux : un login qui contient un retour à la ligne (\n) permet d'ajouter une fausse ligne au fichier de log (« connexion réussie de admin »). Parade : journaux structurés en JSON, ou remplacement des caractères de contrôle (chapitre 12).
  • Injection d'en-têtes HTTP : une saisie placée dans un en-tête de réponse (une redirection, un cookie) avec des retours à la ligne. Les frameworks récents refusent ces caractères.

Valider les entrées

La validation n'est pas la parade principale contre l'injection (c'est la séparation données/code), mais elle réduit fortement la surface d'attaque et attrape les erreurs tôt. Quatre règles :

  1. Côté serveur. La validation dans le formulaire HTML (required, type="email", pattern) améliore l'expérience utilisateur ; elle ne protège de rien, puisqu'on l'ignore avec curl (les formulaires HTML et leur validation).
  2. Par liste blanche. On décrit ce qui est permis (un entier positif, une date au format AAAA-MM-JJ, un mois parmi 202601 à 202612, un état parmi CR, CL, VA, RB) plutôt que de chercher ce qui est interdit.
  3. Type, longueur, format, plage. Un montant est un nombre, positif, inférieur à un plafond ; un libellé est une chaîne de 1 à 100 caractères.
  4. Rejeter plutôt que corriger. Une donnée invalide provoque une erreur 400 ; on ne tente pas de la « nettoyer » pour la rendre acceptable.

Une bibliothèque de schémas comme Zod, Joi ou express-validator rend la validation déclarative :

js
import { z } from 'zod';

const schemaLigneFrais = z.object({
  dateFrais: z.iso.date(),                                    // « 2026-03-12 »
  libelle: z.string().trim().min(1).max(100),
  montant: z.number().positive().max(5000),
});

router.post('/api/fiches/:id/lignes', authentifier, async (req, res) => {
  const resultat = schemaLigneFrais.safeParse(req.body);
  if (!resultat.success) return res.status(400).json({ erreur: 'Données invalides' });
  const { dateFrais, libelle, montant } = resultat.data;     // seuls ces champs sont gardés
  // … requête préparée, avec vérification du propriétaire de la fiche
});

Le schéma garde seulement les champs déclarés : il protège aussi contre l'affectation de masse (chapitre 3). En PHP, filter_var($valeur, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) valide un entier positif ; Symfony et Laravel ont leurs composants de validation.

Limiter les dégâts : le moindre privilège en base

Si une injection passe malgré tout, ce que l'attaquant obtient dépend des droits du compte SQL utilisé par l'application. Avec root, il peut tout lire, tout détruire, créer des comptes et parfois écrire des fichiers sur le serveur. Avec un compte limité :

sql
CREATE USER 'app_frais'@'10.0.1.%' IDENTIFIED BY '…mot de passe long et aléatoire…';
GRANT SELECT, INSERT, UPDATE ON gsb_frais.fiche_frais TO 'app_frais'@'10.0.1.%';
GRANT SELECT, INSERT, UPDATE, DELETE ON gsb_frais.ligne_frais TO 'app_frais'@'10.0.1.%';
GRANT SELECT ON gsb_frais.utilisateur TO 'app_frais'@'10.0.1.%';
GRANT INSERT ON gsb_frais.journal TO 'app_frais'@'10.0.1.%';

L'application ne peut ni supprimer une table, ni modifier un compte, ni effacer le journal. Le chapitre Comptes, rôles et droits du cours SQL détaille GRANT, les rôles et les vues qui restreignent les colonnes visibles.

Exercice 1 sur 4 Repérer les injections Réussi À revoir

Pour chaque extrait, dis s'il est vulnérable et, si oui, à quelle injection.

  1. $pdo->query("SELECT * FROM ligne_frais WHERE id_fiche = " . (int) $_GET['fiche']);
  2. pool.execute('SELECT * FROM utilisateur WHERE email = ?', [req.query.email]);
  3. pool.execute(`SELECT * FROM ligne_frais ORDER BY ${req.query.tri}`);
  4. Utilisateur.find({ service: req.body.service }); (MongoDB, req.body issu d'un JSON)
  5. exec('ping -c 1 ' + req.query.hote);
Voir la réponseMasquer la réponse de l'exercice 1
  1. Pas d'injection dans les faits : la conversion (int) ne laisse passer qu'un entier. Mais c'est une protection fragile (une évolution du code peut l'oublier) : la requête préparée reste la bonne pratique.
  2. Pas d'injection : requête préparée.
  3. Injection SQL : le nom de colonne est concaténé. Un marqueur ne peut pas le remplacer ; il faut une liste blanche de colonnes.
  4. Injection NoSQL : si l'attaquant envoie { "service": { "$ne": null } }, il obtient tous les utilisateurs. Vérifier que service est une chaîne, ou activer sanitizeFilter.
  5. Injection de commandes : avec hote=8.8.8.8;cat /etc/passwd, le shell exécute la seconde commande. Utiliser execFile('ping', ['-c', '1', hote]) après avoir validé que hote est une adresse IP ou un nom d'hôte valide.

Tu avais trouvé ?

Exercice 2 sur 4 Expliquer une charge Réussi À revoir

Dans un formulaire de connexion vulnérable écrit en concaténation, un attaquant saisit ' OR '1'='1 comme identifiant et ' OR '1'='1 comme mot de passe. La requête du code est :

sql
SELECT id FROM utilisateur WHERE login = '<login>' AND mdp = '<mdp>'
  1. Écris la requête que reçoit le SGBD.
  2. Explique pourquoi elle renvoie des lignes. (Rappel : AND est évalué avant OR.)
  3. Quel compte l'attaquant obtient-il le plus souvent, et pourquoi est-ce grave ?
  4. Cite les trois défauts de ce code, au-delà de l'injection elle-même.
Voir la réponseMasquer la réponse de l'exercice 2
  1. SELECT id FROM utilisateur WHERE login = '' OR '1'='1' AND mdp = '' OR '1'='1'
  2. Avec la priorité de AND, la condition se lit login = '' OR ('1'='1' AND mdp = '') OR '1'='1'. Le dernier terme, '1'='1', est toujours vrai : la condition est vraie pour toutes les lignes.
  3. L'application prend en général la première ligne renvoyée, souvent le premier compte créé : l'administrateur. L'attaquant obtient donc les droits les plus élevés.
  4. Le mot de passe est comparé en clair dans la base, donc stocké en clair (A04) ; l'application accepte plusieurs lignes sans vérifier qu'il y en a exactement une ; aucune limite d'essais n'apparaît (A07). On peut ajouter que le compte connecté n'est pas vérifié comme actif.

Tu avais trouvé ?

Exercice 3 sur 4 Corriger un tri Réussi À revoir

Une route liste les fiches avec ?tri=mois&sens=desc et construit :

js
const sql = `SELECT * FROM fiche_frais WHERE id_visiteur = ? ORDER BY ${req.query.tri} ${req.query.sens}`;
  1. Pourquoi ne peut-on pas écrire ORDER BY ? ? ?
  2. Corrige la route pour n'autoriser que le tri par mois, montant_valide ou etat, dans les deux sens.
Voir la réponseMasquer la réponse de l'exercice 3
  1. Un marqueur ne remplace qu'une valeur. Le nom de colonne et le mot-clé de sens font partie de la structure de la requête : passés en paramètres, ils seraient traités comme des constantes, et le tri n'aurait aucun effet.
  2. Avec des listes blanches :
js
const colonnes = { mois: 'mois', montant: 'montant_valide', etat: 'etat' };
const colonne = colonnes[req.query.tri] ?? 'mois';
const sens = req.query.sens === 'asc' ? 'ASC' : 'DESC';
const sql = `SELECT * FROM fiche_frais WHERE id_visiteur = ? ORDER BY ${colonne} ${sens}`;
const [fiches] = await pool.execute(sql, [req.utilisateur.id]);

Seules des chaînes écrites dans le code entrent dans la requête ; l'identifiant du visiteur vient du jeton vérifié.

Tu avais trouvé ?

Exercice 4 sur 4 Injection NoSQL Réussi À revoir

Une API Express 4 (ou Express 5 configurée avec app.set('query parser', 'extended')) reçoit GET /api/visiteurs?region=Normandie et exécute Visiteur.find({ region: req.query.region }). Avec ce parseur, ?region[$ne]=x devient { region: { $ne: 'x' } }.

  1. Que renvoie la requête avec ?region[$ne]=x ?
  2. Propose deux corrections.
Voir la réponseMasquer la réponse de l'exercice 4
  1. Tous les visiteurs dont la région est différente de x, c'est-à-dire tous les visiteurs. L'attaquant contourne le filtre.
  2. Vérifier le type : if (typeof req.query.region !== 'string') return res.status(400).end(); ; ou activer mongoose.set('sanitizeFilter', true), qui transforme l'objet suspect en simple comparaison d'égalité. Un schéma de validation (Zod) sur les paramètres de la requête combine les deux idées.

Avec le parseur par défaut d'Express 5 (« simple »), ?region[$ne]=x n'est pas transformé en objet, mais ?region=a&region=b donne un tableau : le contrôle du type reste nécessaire, et le corps JSON (req.body) peut toujours contenir des objets.

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 : Faille XSS : réfléchie, stockée, DOM et CSPRevoir le sommaire du cours