Les injections (SQL, commandes, code)
La famille de failles la plus classique : injection SQL, de commandes et de code — et la parade décisive des requêtes paramétrées.
L'injection est sans doute la famille d'attaques la plus célèbre du Web, et la plus ancienne. Son principe tient en une phrase : une donnée fournie par l'utilisateur est lue par un interpréteur — base de données, shell, moteur de gabarit — qui finit par l'interpréter non pas comme une donnée, mais comme du code. La cause racine est toujours la même : la confusion entre données et code. Là où le développeur croyait passer une simple valeur, l'interpréteur reçoit des instructions supplémentaires qu'il exécute docilement.
Ce chapitre suit la logique de Hoffman : comprendre d'abord, brièvement et conceptuellement, comment l'injection détourne un interpréteur (SQL, puis code, puis commandes système), pour consacrer l'essentiel à la défense. Vous verrez que la parade la plus connue — la requête paramétrée — n'est pas un pansement, mais une élimination structurelle du risque. Rappel de cadre : ce qui suit est éducatif et défensif. On n'attaque jamais un système tiers ; on teste uniquement ce qu'on est autorisé à tester, et l'on divulgue toute faille de façon responsable.
Le mécanisme commun : un interpréteur et une charge utile
Une attaque par injection a toujours deux ingrédients : un interpréteur (interpreter) et une charge utile (payload) issue de l'utilisateur qui parvient, d'une manière ou d'une autre, jusqu'à cet interpréteur. L'interpréteur peut être un moteur SQL, mais aussi un utilitaire en ligne de commande comme un compresseur vidéo, ou un évaluateur de code. Le dénominateur commun est qu'une chaîne construite par concaténation mélange la structure attendue (la requête, la commande) et une portion contrôlée par l'attaquant.
Note
Hoffman le souligne : contrairement aux failles XXE liées à une spécification faible, l'injection n'est pas le défaut d'un standard. C'est une vulnérabilité qui surgit dès qu'on fait trop confiance aux entrées de l'utilisateur. C'est donc une faille de conception et de code, que le développeur peut éliminer de son côté.
L'injection SQL : comment ça marche
L'injection SQL (SQL injection) est la forme historique. Elle vise l'interpréteur SQL : une entrée de l'utilisateur, concaténée dans une requête, en modifie la logique. Considérez ce serveur Node.js/Express minimaliste qui interroge une base à partir d'un paramètre user_id.
// ❌ VULNÉRABLE : l'entrée est collée directement dans la requête.
app.post('/users', async function (req, res) {
const user_id = req.params.user_id;
await sql.connect('mssql://user:pass@localhost/database');
// Concaténation = la donnée peut devenir de la requête.
const result = await sql.query(
'SELECT * FROM users WHERE USER = ' + user_id
);
return res.json(result);
}); Le développeur a supposé que user_id resterait un identifiant. Mais rien ne garantit qu'une valeur transmise sur le réseau n'a pas été altérée. Quelques exemples conceptuels suffisent à mesurer le danger :
- Toujours vrai. Avec
user_id = '1=1', la requête devient « renvoie toutes les lignes ». C'est la variante du célèbre' OR '1'='1qui contourne un contrôle. - Requêtes empilées (stacked queries). Avec
'123abc; DROP TABLE users;', une seconde instruction est ajoutée — et c'est la destruction de la table. - Modification furtive. Avec
'123abc; UPDATE users SET credits = 10000 WHERE ...', l'attaquant s'octroie discrètement des crédits (ici 10000).
L'historique de PHP illustre pourquoi ce fléau a tant prospéré : l'entremêlement de HTML, de PHP et de SQL dans un même fichier, avec des requêtes bâties par concaténation sans aucune désinfection. De grands projets comme WordPress en ont fait les frais. Hoffman note une bonne nouvelle : grâce à des standards plus stricts, les injections sont passées de près de 5 % des vulnérabilités en 2010 à moins de 1 % aujourd'hui (National Vulnerability Database). Elles restent toutefois courantes dans le code qui ignore les bonnes pratiques.
Attention
Le réflexe « je nettoie d'abord la chaîne, puis je concatène » est un piège. Filtrer des caractères jugés dangereux (apostrophes, points-virgules) revient à tenir une liste de blocage (blacklist) toujours incomplète. La seule garantie solide vient d'une séparation structurelle entre la requête et les données — c'est l'objet de la section sur les requêtes paramétrées.
L'injection de code et l'injection de commandes
L'injection SQL n'est qu'un sous-ensemble. Toute API qui pousse une entrée vers un interpréteur ou un CLI est exposée. Hoffman prend l'exemple d'un serveur de conversion vidéo. Le code concatène les options de l'utilisateur dans une commande passée à un convertisseur :
// ❌ VULNÉRABLE : options utilisateur concaténées dans une commande.
const exec = require('child_process').exec;
app.post('/uploadVideo', function (req, res) {
if (!session.isAuthenticated) return res.sendStatus(401);
const videoData = req.body.video;
const videoName = req.body.name;
const options = defaultOptions + req.body.options;
exec(`convert -d ${videoData} -n ${videoName} -o ${options}`);
}); Tant que les options restent légitimes (-c h264 -ab 192k), tout va bien. Mais selon l'architecture du CLI — certains acceptent plusieurs commandes par ligne, d'autres sont rompus par un saut de ligne, un espace ou un && — l'attaquant peut enchaîner une instruction supplémentaire destinée au convertisseur. On parle alors d'injection de code (code injection) : le script malveillant s'exécute sous l'interpréteur ou le CLI, pas contre le système hôte.
La marche au-dessus est l'injection de commandes (command injection). Si la même chaîne est exécutée par le shell du système, et qu'on peut échapper aux guillemets, on bascule du CLI vers le système d'exploitation entier :
# Ce que l'attaquant cherche à provoquer côté hôte (illustratif).
$ convert -d vidData.mp4 -n myVid.mp4 -o '-s 1280x720' && rm -rf /videos La différence est capitale. L'injection de code se limite à un interpréteur ; l'injection de commandes expose tout le système. Sur un serveur Unix (plus de 95 % des serveurs), un attaquant disposant d'un shell peut lire /etc/passwd, /etc/shadow, les clés ~/.ssh, les configurations Apache ou Nginx — voler des données, réécrire les journaux pour effacer ses traces, ajouter un compte de base de données disposant de droits d'écriture (pour une persistance d'accès), supprimer des fichiers, détourner les clés d'API d'intégrations tierces, transformer un formulaire de connexion en formulaire de hameçonnage, ou verrouiller les administrateurs. C'est, dit Hoffman, l'une des attaques les plus dangereuses qui soient, en haut de toutes les échelles de risque.
Piège courant
Une injection peut se cacher derrière une fonctionnalité d'apparence anodine. Dans l'exemple d'Hoffman, un endpoint d'upload d'image qui se contente d'accepter un nom de fichier est déjà un vecteur : la bibliothèque sous-jacente invoque un CLI, et une collision de noms de fichiers permet d'écraser des images existantes. Le danger ne vient pas seulement de votre code, mais aussi des dépendances qui appellent des CLI à votre insu.
Se défendre contre l'injection SQL
L'injection SQL est la plus répandue, mais aussi l'une des plus faciles à contrer : les défenses sont mûres depuis plus de vingt ans. Comme l'attaque se produit dans l'interpréteur SQL, elle est aussi relativement simple à détecter dans une base de code.
Repérer les zones à risque
Avant de défendre, il faut savoir où regarder. Les opérations SQL se situent côté serveur, après le routage : inutile de fouiller le client. Pensez aussi aux modules annexes (par exemple un service d'analytics) qui persistent des données et utilisent donc, eux aussi, une base. Recensez toutes les implémentations SQL et tous les langages dédiés (DSL) de votre application — une même app peut mêler SQL Server et MySQL. En Node.js, le système de modules facilite la traque : chercher l'import de l'adaptateur (require('mssql'), require('mysql')) ou les appels du type .query(...) localise les requêtes. Méfiez-vous des bibliothèques qui construisent du SQL via une syntaxe fluide (q.where('username = ' + username)) : l'interpolation de variables y est tout aussi dangereuse.
Les requêtes préparées : LA première ligne de défense
La parade décisive, ce sont les requêtes préparées (prepared statements), aussi appelées requêtes paramétrées (parameterized queries). Le principe est simple et puissant : la requête est compilée d'abord, avec des variables de liaison (bind variables) — de simples marqueurs ? à la place des valeurs. Ce n'est qu'ensuite que les valeurs fournies par le développeur viennent remplir ces marqueurs.
Cette inversion change tout. Dans une requête classique, la structure et les données partent ensemble, sous forme d'une seule chaîne ; manipuler les données peut donc changer l'intention. Avec une requête préparée, l'intention est gravée dans le marbre avant que la moindre donnée utilisateur n'atteigne l'interpréteur. Conséquence : un SELECT ne peut plus être détourné en DELETE, et aucune requête supplémentaire ne peut être empilée après coup. Les données fournies ne sont jamais interprétées comme de la requête.
Voici la version sûre en MySQL pur :
-- ✅ CORRIGÉ : la requête est compilée AVANT les données.
PREPARE q FROM 'SELECT name, barCode FROM products WHERE price <= ?';
SET @price = 12;
EXECUTE q USING @price;
DEALLOCATE PREPARE q; Même si @price valait 5; UPDATE users SET balance = 10000, l'instruction parasite ne se déclencherait pas : elle n'a jamais été compilée comme requête. La version dangereuse, à bannir, est la concaténation :
-- ❌ VULNÉRABLE : la donnée et la requête voyagent ensemble.
'SELECT name, barcode FROM products WHERE price <= ' + price + ';' Transposons le serveur Node.js vulnérable du début vers une version paramétrée. La donnée passe désormais par un paramètre lié, jamais par la chaîne de requête :
// ✅ CORRIGÉ : requête paramétrée via une variable de liaison.
app.post('/users', async function (req, res) {
const userId = req.params.user_id;
await sql.connect('mssql://user:pass@localhost/database');
const request = new sql.Request();
request.input('userId', sql.Int, userId); // typage + liaison
const result = await request.query(
'SELECT * FROM users WHERE USER = @userId'
);
return res.json(result);
}); Les requêtes préparées sont prises en charge par presque toutes les grandes bases (MySQL, Oracle, PostgreSQL, Microsoft SQL Server). Leur unique contrepartie est une légère perte de performance : la base reçoit la requête, la compile, puis reçoit les variables — deux étapes au lieu d'une. Dans la plupart des applications, ce coût est négligeable au regard du gain de sécurité.
À retenir
Hoffman qualifie les requêtes préparées de première ligne de défense : faciles à implémenter, bien documentées, et hautement efficaces. La règle pratique est sans nuance : paramétrez partout où c'est possible. Si vous écrivez une requête par concaténation, considérez-la comme un bug de sécurité.
Les ORM, à utiliser sans échappatoire
Un mappeur objet-relationnel (ORM) correctement employé génère des requêtes paramétrées pour vous, ce qui réduit considérablement le risque. Le danger se niche dans les échappatoires : la plupart des ORM offrent un mode « SQL brut » (raw query) où vous reprenez la main sur la chaîne. Dès qu'on y concatène une entrée, on réintroduit exactement la faille que l'ORM évitait. La discipline : préférer l'API de haut niveau, et si le SQL brut est inévitable, le paramétrer lui aussi.
Échappement spécifique à la base : un dernier recours, pas une garantie
Chaque base offre des fonctions d'échappement des caractères risqués : QUOTE() et mysql_real_escape_string() en MySQL, l'encodeur ESAPI encodeForSQL(new OracleCodec(), str) côté Oracle. Ces outils rendent plus difficile l'écriture d'un littéral SQL malveillant. Mais Hoffman est clair : ils constituent une mitigation, pas une défense complète. On ne les emploie que lorsqu'une requête ne peut absolument pas être paramétrée — jamais à la place du paramétrage.
| Défense | Rôle | Statut |
|---|---|---|
| Requête préparée / paramétrée | Sépare structure et données | Défense principale |
| ORM bien utilisé | Génère du SQL paramétré | Défense, si pas de SQL brut |
| Échappement spécifique à la base | Désinfecte les caractères risqués | Mitigation, dernier recours |
| Validation par liste d'autorisation | Rejette les entrées hors format | Couche complémentaire |
| Moindre privilège de la base | Limite les dégâts d'une brèche | Défense en profondeur |
Se défendre contre les autres injections
Au-delà du SQL, appliquez des pratiques sûres par défaut à toute la logique applicative. Une injection peut viser n'importe quel utilitaire ou interpréteur qui lit du texte et l'évalue.
Identifier les cibles potentielles
Quand vous classez vos composants par risque, commencez par cette liste de cibles à haut risque identifiées par Hoffman :
- les planificateurs de tâches ;
- les bibliothèques de compression/optimisation ;
- les scripts de sauvegarde distante ;
- les bases de données ;
- les journaux (loggers) ;
- tout appel au système d'exploitation hôte ;
- tout interpréteur ou compilateur.
N'oubliez pas que vos dépendances tirent leurs propres sous-dépendances, qui tombent souvent dans ces catégories.
Le principe de moindre autorité
Le principe de moindre autorité (principle of least authority), plus connu sous le nom de moindre privilège (least privilege), énonce que chaque membre d'un système ne doit avoir accès qu'aux informations et ressources strictement nécessaires à sa tâche. Appliqué au logiciel : chaque module n'accède qu'aux données et fonctionnalités requises pour fonctionner correctement.
Concrètement, le compte que votre application utilise pour se connecter à la base ne devrait pas disposer du droit DROP, ni de droits d'administration superflus. De même, un CLI de sauvegarde de photos ne devrait pas tourner en administrateur : ainsi, même compromis, il ne compromet pas le reste du serveur. Ce principe paraît contraignant — comptes supplémentaires, clés multiples — mais il réduit drastiquement l'impact d'une brèche. Sur Unix, le système de permissions fines (fichiers, répertoires, utilisateurs, commandes) permet de forcer une API à s'exécuter sous un utilisateur non privilégié : c'est une mitigation puissante, trop rarement mise en place.
Astuce
Le moindre privilège ne supprime pas la vulnérabilité, il en plafonne le coût. Combiné aux requêtes paramétrées, il forme une défense en profondeur : la première barrière empêche l'injection, la seconde limite les dégâts si une faille passe malgré tout.
Bannir le shell, séparer la commande de ses arguments
Pour les commandes système, la règle d'or est de ne jamais appeler le shell avec une entrée utilisateur. Le problème de exec('convert ... ' + options) est que tout transite par une seule chaîne interprétée par le shell, qui réagit aux &&, ;, sauts de ligne et guillemets. La parade est d'utiliser des API qui séparent la commande de ses arguments, en passant ces derniers sous forme de tableau : ils ne sont alors plus réinterprétés par un shell.
// ❌ VULNÉRABLE : tout passe par le shell, en une chaîne.
const exec = require('child_process').exec;
exec(`rm /videos/raw/${req.body.name}`);
// ✅ CORRIGÉ : pas de shell ; arguments séparés et non interprétés.
const { execFile } = require('child_process');
execFile('rm', ['/videos/raw/' + sanitizedName], (err) => {
if (err) return res.sendStatus(500);
return res.sendStatus(200);
}); Avec execFile, même un name contenant myVideo.mp4 && rm -rf /videos est traité comme un seul argument littéral, pas comme deux commandes. Mieux encore : pour une suppression de fichier, préférez une API de système de fichiers (fs.unlink) qui ne fait intervenir aucun shell.
Valider par liste d'autorisation et par typage
Le pire des modèles est celui où le client envoie des commandes à exécuter sur le serveur. Hoffman est catégorique : cette architecture est à proscrire. Quand une entrée utilisateur doit malgré tout se traduire en opération serveur, on n'interprète jamais la commande littéralement : on la confronte à une liste d'autorisation (allowlist) de commandes vérifiées.
// ❌ VULNÉRABLE : le client exécute n'importe quelle commande du CLI.
const postCommands = function (req, res) {
cli.run(req.body.commands);
};
// ✅ CORRIGÉ : seules les commandes de l'allowlist passent.
const allowed = ['print', 'cut', 'copy', 'paste', 'refresh'];
const postCommands = function (req, res) {
const userCommands = req.body.commands;
for (const c of userCommands) {
if (!allowed.includes(c)) return res.sendStatus(400);
}
cli.run(userCommands);
}; Pourquoi une liste d'autorisation et non de blocage ? Parce que les applications évoluent : une liste de blocage devient obsolète dès qu'une nouvelle commande dangereuse apparaît, alors qu'une liste d'autorisation reste sûre par défaut. Le même esprit s'applique aux données : typez et validez strictement les entrées (un identifiant numérique doit être un entier, pas une chaîne libre), et préférez toujours autoriser un ensemble connu plutôt que d'essayer de bloquer l'inconnu.
| Type d'injection | Interpréteur visé | Contre-mesure principale |
|---|---|---|
| Injection SQL | Moteur SQL (MySQL, PostgreSQL…) | Requête préparée / paramétrée |
| Injection de code | CLI, moteur de gabarit, eval | Allowlist de commandes, pas d'eval |
| Injection de commandes | Shell du système hôte | API sans shell, arguments séparés |
| Via une dépendance | CLI appelé en sous-main | Audit des dépendances, moindre privilège |
Attention
Ne déléguez jamais à la validation côté client le soin d'empêcher une injection. Le client est sous le contrôle de l'attaquant : un formulaire bridé en HTML ou en JavaScript se contourne en quelques secondes. Toute validation, toute paramétrisation et toute autorisation doivent être imposées côté serveur.
À retenir
- La cause racine est la confusion entre données et code. Toute entrée qui atteint un interpréteur (SQL, shell, CLI, gabarit) sans séparation stricte peut être interprétée comme une instruction.
- Les requêtes préparées / paramétrées sont LA défense contre l'injection SQL. La requête est compilée avant que la donnée utilisateur n'arrive : celle-ci n'est jamais traitée comme de la requête. Paramétrez partout, considérez la concaténation comme un bug.
- Les ORM aident, à condition d'éviter le SQL brut ; si le SQL brut est inévitable, paramétrez-le. L'échappement spécifique à la base n'est qu'un dernier recours, jamais une garantie.
- Pour les commandes système, bannissez le shell avec une entrée utilisateur : utilisez des API qui séparent la commande de ses arguments (tableau d'arguments,
fs.unlink…). - Validez par liste d'autorisation et par typage, jamais par liste de blocage ; rejetez tout ce qui sort du format attendu, et imposez la validation côté serveur.
- Appliquez le moindre privilège : un compte de base de données sans droit
DROP, des processus non privilégiés. La faille devient ainsi beaucoup moins coûteuse si elle survient.