Ce qu'est une injection SQL
L'injection SQL (SQLi) est une vulnérabilité où un texte fourni par un utilisateur est collé dans une requête de base de données, et où la base finit par l'exécuter comme du SQL. L'application avait l'intention d'envoyer une valeur, comme une adresse e-mail ou un identifiant de produit ; l'attaquant envoie à la place un fragment de langage de requête, et le sens de la requête change.
La cause profonde est toujours la même : du code qui construit une requête par concaténation de chaînes, mélangeant deux types de données dans une seule chaîne. Le texte de la requête, c'est des instructions que vous avez écrites. La valeur, c'est une donnée écrite par quelqu'un d'autre. Une fois collées ensemble, la base de données n'a aucun moyen de savoir où s'arrêtent vos instructions et où commence la saisie du visiteur.
C'est pourquoi l'injection SQL n'est spécifique ni à MySQL, PostgreSQL, SQL Server ou SQLite, ni au PHP d'ailleurs. N'importe quel langage, n'importe quel driver et n'importe quelle base de données est vulnérable dès qu'une requête est assemblée à partir de chaînes non fiables. Elle est cataloguée sous CWE-89, et l'injection a figuré dans chaque édition de l'OWASP Top 10. L'ensemble de recherches qui l'entoure, « attaque SQL », « vulnérabilité SQL », « attaque par injection SQL », décrit tout cette même erreur.
La bonne nouvelle, c'est que la correction est tout aussi uniforme, vieille de plusieurs décennies et intégrée dans chaque driver de base de données utilisé aujourd'hui : envoyer la requête et les valeurs séparément.
Comment ça marche : l'exemple classique
Prenez un contrôle de connexion écrit comme d'innombrables tutoriels l'ont écrit autrefois. Le nom d'utilisateur et le mot de passe viennent d'un formulaire et sont déposés directement dans la chaîne SQL.
Avec une saisie normale, la requête fait ce qu'elle doit. Imaginez maintenant qu'un visiteur tape ' OR '1'='1 dans le champ mot de passe. Le guillemet ferme le littéral de chaîne que le développeur avait ouvert, et le reste devient une partie de la clause WHERE. Comme '1'='1' est toujours vrai et que AND se lie plus fort que OR, la condition correspond à toutes les lignes, et le code connecte le visiteur comme le premier utilisateur de la table, qui est souvent l'administrateur.
Ce seul guillemet résume tout le concept. Tout le reste de ce guide, les différents types d'attaque, l'impact et les défenses, découle du fait que la base de données a reçu une seule chaîne et a interprété le texte de l'attaquant comme du code.
(Stocker des mots de passe en clair et les comparer en SQL est un deuxième bug dans cet exemple. Le vrai code récupère l'utilisateur par son nom et vérifie le mot de passe avec password_verify() contre un hachage. Il est gardé ici parce que c'est le moyen le plus court de montrer l'injection.)
Vulnérable : entrée utilisateur concaténée dans la requête (PHP)
<?php
// DO NOT DO THIS
$user = $_POST['username'];
$pass = $_POST['password'];
$sql = "SELECT id FROM users
WHERE username = '$user' AND password = '$pass'";
$row = $pdo->query($sql)->fetch();
// With password ' OR '1'='1 the database receives:
// SELECT id FROM users
// WHERE username = 'admin' AND password = '' OR '1'='1'
// ...which is true for every row.
La correction : requêtes paramétrées, en PHP et en Python
Une requête paramétrée (aussi appelée prepared statement ou paramètres liés) envoie le texte SQL avec des espaces réservés, ? ou :name ou %s selon le driver, et envoie les valeurs séparément. La base de données analyse et planifie d'abord la requête, puis y insère les valeurs comme données. Un guillemet dans une valeur n'est qu'un caractère dans une chaîne ; il ne peut jamais fermer un littéral ni ajouter une clause, parce que l'analyse est déjà terminée.
Ce n'est pas une étape de nettoyage qui pourrait manquer un cas. Cela supprime entièrement le mécanisme, ce qui explique sa place en tête de toute liste de prévention.
En PHP, utilisez PDO ou mysqli avec des espaces réservés. Avec PDO, fixez le jeu de caractères dans le DSN et désactivez les prepares émulés pour que le driver envoie de vrais prepared statements côté serveur, et activez les exceptions pour que les échecs ne soient pas ignorés silencieusement. En Python, chaque driver DB-API (sqlite3, psycopg, mysqlclient, PyMySQL) prend les valeurs comme un argument séparé de execute(). Le piège en Python est de formater vous-même la chaîne avec une f-string ou % avant d'appeler execute() : le résultat a l'air paramétré mais c'est de la concaténation.
La même règle vaut dans toute autre pile technique : PreparedStatement en Java, SqlParameter en .NET, les espaces réservés $1 dans node-postgres, ? dans database/sql de Go.
Sûr : espaces réservés, valeurs envoyées séparément (PHP PDO et Python)
<?php
$pdo = new PDO('mysql:host=db;dbname=shop;charset=utf8mb4', $dbUser, $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$_POST['username']]);
$row = $stmt->fetch();
$ok = $row && password_verify($_POST['password'], $row['password_hash']);
# Python (psycopg 3, PostgreSQL)
cur.execute(
"SELECT id, password_hash FROM users WHERE username = %s",
(username,), # values go here, never into the string
)
# WRONG, still injectable: the string is built before execute() sees it
cur.execute(f"SELECT id FROM users WHERE username = '{username}'")
Types d'injection SQL
Les articles de sécurité classent l'injection SQL selon comment l'attaquant récupère les données. La vulnérabilité est la même dans chaque cas ; ce qui diffère, c'est ce que l'application révèle.
In-band, basée sur UNION. Le résultat de la requête injectée revient dans la page elle-même. En ajoutant un UNION SELECT, l'attaquant ajoute des lignes d'une autre table, disons la table des utilisateurs, à la liste de produits que la page affichait déjà. C'est le type le plus rapide à exploiter et le plus facile à repérer en test.
In-band, basée sur les erreurs. L'application affiche les messages d'erreur de la base de données. L'attaquant provoque des erreurs dont le texte contient les données qu'il veut, comme un nom de table ou une valeur. Montrer les erreurs SQL brutes aux visiteurs transforme un bug aveugle en bug lisible, ce qui est une bonne raison de journaliser les erreurs côté serveur et de montrer une page générique aux utilisateurs.
Aveugle, basée sur un booléen. La page n'affiche ni données ni erreurs, mais elle se comporte différemment selon qu'une condition est vraie ou fausse : un produit apparaît ou non, une page est en 200 ou en 404. En posant des questions oui/non une par une, un attaquant lit les données petit à petit. Lent, mais des outils l'automatisent.
Aveugle, basée sur le temps. Même le contenu de la page est identique, donc l'attaquant fait attendre la base de données quand une condition est vraie et mesure le temps de réponse. Si chaque réponse semble identique, le timing reste un canal.
Hors bande. L'attaquant fait contacter par le serveur de base de données lui-même un système qu'il contrôle, typiquement via une résolution DNS ou une requête HTTP déclenchée par une fonction de la base. Cela dépend des fonctionnalités de la base et de la disponibilité d'une sortie réseau, ce qui est une raison de plus pour qu'un serveur de base de données ne puisse pas ouvrir de connexions sortantes dont il n'a pas l'usage.
Second ordre (stockée). L'entrée est stockée en sécurité la première fois, correctement paramétrée, puis relue plus tard et concaténée dans une autre requête, souvent dans un rapport admin ou une tâche de fond. Les développeurs font confiance aux données venant de leur propre base ; cette confiance est le bug. La défense est la même que partout ailleurs : paramétrer chaque requête, y compris celles construites à partir de valeurs que vous avez vous-même stockées.
Ce qu'un attaquant obtient réellement
L'impact est borné par ce que le compte de base de données utilisé par l'application est autorisé à faire, ce qui explique pourquoi le moindre privilège compte tant plus loin dans ce guide.
Lire des données. Le résultat courant : dossiers clients, adresses e-mail, hachages de mot de passe, commandes, clés d'API stockées dans des tables de paramètres. Toute table que ce compte peut lire avec SELECT est lisible, pas seulement celle que touche la requête vulnérable.
Contourner l'authentification. Comme dans l'exemple de connexion, une condition censée être spécifique devient toujours vraie.
Modifier ou détruire des données. Si le compte peut faire UPDATE, INSERT ou DELETE, l'attaquant aussi : changer les prix, créer des utilisateurs admin, vider des tables. Certaines combinaisons de driver et de base de données permettent plusieurs instructions en un seul appel, ce qui élargit encore cette possibilité.
Atteindre le serveur. Avec des privilèges puissants, certaines bases peuvent lire ou écrire des fichiers sur l'hôte ou exécuter des commandes du système d'exploitation. Sur un compte de base de données avec des droits administratifs, une injection SQL peut devenir une compromission complète du serveur.
Ce n'est pas théorique. L'injection SQL a été le point d'entrée de la brèche Heartland Payment Systems de 2008, où les données de bien plus de 100 millions de cartes ont été volées ; de la brèche TalkTalk de 2015 au Royaume-Uni, qui a entraîné une amende réglementaire ; et de l'exploitation massive de MOVEit Transfer en 2023 (CVE-2023-34362), où une seule faille d'injection dans un produit de transfert de fichiers a été utilisée contre des milliers d'organisations. Vieux bug, titres d'actualité actuels.
Prévention, par ordre d'importance
1. Requêtes paramétrées partout. Chaque requête, chaque valeur, y compris les valeurs venant de votre propre base de données, des cookies, des en-têtes et des services internes. Cela seul referme la vulnérabilité. Les autres étapes limitent les dégâts quand quelqu'un, quelque part, oublie.
2. Utilisez correctement votre ORM ou query builder, et connaissez ses issues de secours. Eloquent, Doctrine, Django ORM, SQLAlchemy, Hibernate et Entity Framework paramètrent les requêtes ordinaires pour vous. Ils ne protègent pas les fragments bruts : whereRaw, DB::raw, selectRaw et orderByRaw dans Laravel, .extra() et .raw() dans Django, text() dans SQLAlchemy, FromSqlRaw dans EF Core. Tous acceptent des liaisons (bindings) ; utilisez-les. Grepper ces noms de méthode est la revue de code la plus rapide que vous puissiez faire.
3. Mettez sur liste blanche ce qui ne peut pas être un paramètre. Les espaces réservés contiennent des valeurs, pas des identifiants. Un nom de table, une colonne dans ORDER BY ou les mots ASC/DESC ne peuvent pas être liés, donc un paramètre « trier par » doit être fait correspondre à une liste fixe de noms de colonnes dans le code. Ne le laissez jamais passer tel quel.
4. Moindre privilège pour l'utilisateur de base de données. L'application web devrait se connecter avec un utilisateur qui peut faire SELECT, INSERT, UPDATE et DELETE sur son propre schéma et rien d'autre : pas de DROP, pas de GRANT, pas d'accès aux fichiers, pas aux autres bases, et jamais le compte root ou sa. Les migrations tournent avec un utilisateur séparé, plus puissant. Si une injection passe à travers, c'est ce qui décide si elle fuit un schéma ou s'approprie le serveur.
5. Validation des entrées en défense en profondeur. Un identifiant de commande devrait être un entier, une date devrait s'analyser comme une date, un code pays fait deux lettres. Valider les types et les formats à la frontière de votre application rejette tôt beaucoup d'entrées malveillantes et attrape des bugs. Ce n'est pas la correction : un champ nom doit accepter O'Brien, et un champ commentaire accepte presque n'importe quoi.
6. Ne laissez pas fuir les erreurs. Journalisez les erreurs de base de données côté serveur avec la requête et le contexte ; montrez au visiteur une page d'erreur générique. En PHP, cela signifie display_errors=Off en production.
Fragments bruts avec liaisons, tri sur liste blanche, et utilisateur à privilège minimal
// Laravel: raw expressions must carry their own bindings
$orders = DB::table('orders')
->whereRaw('total > ? AND status = ?', [$min, $status])
->get();
// ORDER BY cannot be bound: map input to known columns
$sortable = ['created_at', 'total', 'status'];
$column = in_array($request->sort, $sortable, true) ? $request->sort : 'created_at';
$dir = $request->dir === 'asc' ? 'asc' : 'desc';
$orders = Order::orderBy($column, $dir)->paginate(50);
-- MySQL: the app user gets data access to its own schema only
CREATE USER 'shop_app'@'10.0.%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'10.0.%';
Pourquoi l'échappement ne suffit pas
Avant que les requêtes préparées ne soient courantes, le conseil était d'échapper les guillemets : addslashes(), puis mysql_real_escape_string(), puis mysqli::real_escape_string(). L'échappement est encore présent dans beaucoup de code, et il échoue de manières prévisibles.
Il ne protège que les contextes entre guillemets. WHERE id = $id n'a pas de guillemets autour de la valeur, donc il n'y a rien à échapper, et une entrée comme 1 OR 1=1 passe sans être touchée. Il en va de même pour LIMIT, ORDER BY et tout ce qui est numérique.
Il dépend du jeu de caractères. Les fonctions d'échappement doivent être en accord avec l'encodage de la connexion. Des incohérences entre le jeu de caractères du client et du serveur ont permis à des séquences multi-octets d'avaler la barre oblique inverse d'échappement ; addslashes() n'a jamais connu l'encodage du tout.
Il doit être rappelé absolument chaque fois. Un appel oublié dans une requête, ou une valeur échappée pour le HTML au lieu du SQL, et le trou revient. Les requêtes paramétrées font du chemin sûr le chemin par défaut.
Le même raisonnement s'applique aux listes noires et aux fonctions de « nettoyage » qui retirent des mots comme SELECT ou --. Les attaquants ont passé vingt ans à trouver des encodages, des commentaires et des variations de casse qui passent sous de tels filtres. Traitez tout filtre fait maison comme une commodité, jamais comme une protection.
Tester son propre application en toute sécurité
Commencez par le code, pas par l'attaque. La plupart des injections SQL sont visibles dans une recherche de code : des requêtes assemblées avec ., +, de l'interpolation de chaîne ou sprintf, et des méthodes raw d'ORM appelées avec une variable. Des outils d'analyse statique comme Semgrep et CodeQL embarquent des règles exactement pour ce schéma et peuvent tourner en CI sur chaque pull request.
Écrivez ensuite des tests qui passent des valeurs d'apparence hostile mais inoffensives, un simple guillemet, un nom comme O'Brien, une très longue chaîne, à travers vos formulaires et API, et vérifiez que l'application renvoie un résultat normal ou une erreur de validation, jamais un 500 et jamais un message d'erreur de base de données. Ces tests restent dans la suite et protègent contre les régressions.
Pour des tests dynamiques, des scanners open source comme OWASP ZAP et sqlmap sondent des applications en marche à la recherche de paramètres injectables. sqlmap en particulier est un outil d'exploitation : il extrait des données quand il trouve un trou. Ne l'exécutez que sur des systèmes que vous possédez ou pour lesquels vous avez une autorisation écrite, de préférence une copie de staging avec de fausses données, parce que ses sondes écrivent dans les journaux, peuvent modifier des données et peuvent déclencher votre surveillance. Scanner le site de quelqu'un d'autre sans autorisation est illégal dans la plupart des pays, quelle que soit l'intention.
Si vous testez à travers votre CDN, attendez-vous à ce que le WAF bloque de nombreuses sondes. C'est le WAF qui fait son travail, mais cela masque aussi le vrai comportement de l'application, donc testez l'application elle-même en staging et traitez le WAF comme une couche séparée.
# PHP: query calls that mention a variable (review each hit)
grep -rnE '(query|exec|prepare)\(.*\$[A-Za-z_]' --include='*.php' app/
# Laravel / Eloquent raw fragments: check each one has bindings
grep -rnE '(whereRaw|selectRaw|orderByRaw|havingRaw|DB::raw)\(' app/
# Python: f-strings or % formatting inside execute()
grep -rnE "execute\(\s*f['\"]|execute\(.*['\"]\s*%\s" --include='*.py' .
Où se place un WAF : une couche, pas la correction
Un web application firewall inspecte chaque requête avant qu'elle n'atteigne votre application et bloque celles qui ressemblent à des attaques. L'injection SQL laisse des formes reconnaissables dans les chaînes de requête et les corps de formulaire, donc c'est l'une des choses qu'un WAF fait bien : il arrête les scanners automatisés qui sondent tous les sites d'internet, et il achète du temps quand une faille d'un plugin vulnérable est divulguée avant que vous ne puissiez corriger.
Cela reste de la reconnaissance de motifs contre une vulnérabilité qui vit dans votre code. Un attaquant déterminé façonne la charge utile pour éviter les motifs, et certains points d'injection, comme des champs JSON dans un encodage inhabituel, des valeurs qui atteignent une requête via une tâche de fond, ou l'injection de second ordre, ne semblent jamais suspects dans une requête. Le guide OWASP Top 10 détaille ce qu'un WAF peut et ne peut pas couvrir. Les requêtes paramétrées restent la correction.
Le coût opérationnel, ce sont les faux positifs. Toute règle assez stricte pour attraper l'injection bloquera parfois une personne : un éditeur qui enregistre un article avec un exemple de code, un formulaire de support où quelqu'un colle un message d'erreur contenant du SQL. La pratique habituelle est de faire tourner les nouvelles règles d'abord en mode détection (log seulement), de lire ce qui aurait été bloqué, puis de passer en application, avec des exceptions étroites là où un champ transporte légitimement du texte ressemblant à du SQL.
Sur CDN.com.tr, le WAF est ModSecurity avec l'OWASP Core Rule Set, activé par compte depuis la page des règles de diffusion. Dans ce jeu de règles, la famille 942 est celle des règles d'injection SQL. Un visiteur bloqué reçoit une page 403 personnalisée avec un identifiant de référence, qui est l'identifiant de requête dans le journal d'audit, donc vous pouvez voir exactement quelle règle a matché et sur quel champ. La page des journaux WAF du panneau liste les événements bloqués avec la catégorie d'attaque, le pays, l'IP et la règle, exporte en CSV ou XLSX, et cdnctl waf logs montre la même chose depuis la ligne de commande. Quand un champ légitime déclenche une règle, la correction doit être étroite : cette règle, ce champ, ce chemin, plutôt que désactiver la protection pour tout le site. Les exceptions pour des chemins individuels ne sont pas encore disponibles dans le panneau, donc envoyez l'identifiant de référence de la requête au support. Pour les formulaires de connexion, la limitation de débit par route se trouve sur la même page et ralentit le trafic de force brute qui accompagne souvent les scans d'injection.
FAQ injection SQL
L'injection SQL est-elle encore un problème en 2026 ?
Oui. Les frameworks modernes font du chemin sûr le défaut, mais les requêtes brutes, le code historique, les plugins et les outils internes rapides concatènent encore des chaînes, et de nouvelles failles d'injection dans des produits largement utilisés continuent d'être divulguées et exploitées à grande échelle. L'injection a figuré dans chaque édition de l'OWASP Top 10.
Les requêtes préparées préviennent-elles toute injection SQL ?
Elles préviennent l'injection à travers chaque valeur que vous liez. Elles ne peuvent pas lier des identifiants comme des noms de table ou de colonne, ou la direction du tri, et elles n'aident pas si vous construisez d'abord une chaîne puis « préparez » le résultat. Mettez les identifiants sur liste blanche et ne formatez jamais une entrée dans le texte SQL.
Utiliser un ORM me protège-t-il de l'injection SQL ?
En grande partie, pour les requêtes ordinaires. Chaque ORM a des méthodes brutes, comme whereRaw, DB::raw, .raw(), text() ou FromSqlRaw, qui laissent passer le SQL sans changement. Utilisez-les avec des liaisons, et relisez chaque appel.
La validation des entrées suffit-elle à arrêter l'injection SQL ?
Non. La validation est une seconde ligne utile : un identifiant devrait être numérique et une date devrait s'analyser. Mais de nombreux champs doivent accepter des guillemets et du texte libre, donc la validation ne peut pas être la protection. Les requêtes paramétrées le sont.
Un WAF peut-il arrêter complètement l'injection SQL ?
Il arrête la plupart des tentatives automatisées et augmente l'effort pour les tentatives ciblées, mais il fait correspondre des motifs dans les requêtes et un attaquant déterminé peut adapter une charge utile pour les éviter. L'injection de second ordre ne semble jamais suspecte dans une requête. Utilisez un WAF comme couche de profondeur, avec les requêtes paramétrées comme correction.
Est-il légal de tester un site web pour l'injection SQL avec sqlmap ?
Seulement sur des systèmes que vous possédez ou pour lesquels vous avez une autorisation écrite explicite. Scanner le site de quelqu'un d'autre est un accès non autorisé dans la plupart des juridictions. Testez votre propre environnement de staging avec de fausses données ; si vous trouvez une faille dans le produit de quelqu'un d'autre, signalez-la via son processus de divulgation.
Quelle est la différence entre l'injection SQL et le XSS ?
L'injection SQL fait exécuter à votre base de données du SQL écrit par l'attaquant. Le cross-site scripting fait exécuter au navigateur d'un visiteur du JavaScript écrit par l'attaquant dans le contexte de votre site. Les deux sont de l'injection, avec la même cause profonde de mélanger données et code, et chacune a besoin de sa propre correction : requêtes paramétrées pour le SQL, encodage de sortie sensible au contexte pour le HTML. Voir Qu'est-ce que le XSS ?.