Objectifs
À la fin de ce cours, tu seras capable de :
- Identifier les points d'entrée où une donnée contrôlée par l'utilisateur atteint une requête SQL (paramètres, corps JSON, en-têtes, données stockées).
- Analyser la cause racine d'une injection SQL : la confusion entre le plan du code (la requête) et le plan des données (la valeur).
- Démontrer l'existence d'une injection par des techniques non destructives : comparaison d'erreurs, de réponses booléennes ou de temps de réponse.
- Évaluer la sévérité réelle en fonction des privilèges du compte SGBD, de l'authentification requise et des données accessibles.
- Rédiger un rapport qui apporte une preuve minimale (version du SGBD ou utilisateur courant) sans jamais extraire de données métier.
- Recommander une correction robuste : requêtes préparées, ORM utilisé correctement, allow-list pour les identifiants et moindre privilège.
Pourquoi c'est important en bug bounty
L'injection SQL (CWE-89) appartient à la catégorie A03:2021 « Injection » de l'OWASP Top 10. Elle est moins fréquente qu'il y a quinze ans, car les frameworks modernes paramètrent les requêtes par défaut, mais elle n'a pas disparu : on la retrouve dans le code hérité, dans les requêtes de tri et de filtrage construites dynamiquement, dans les rapports et exports, dans les micro-services écrits à la hâte et dans les appels « bruts » qui contournent l'ORM.
Quand elle est confirmée, la sévérité attribuée est presque toujours élevée à critique : une injection non authentifiée donne potentiellement accès à toute la base (comptes, hachés de mots de passe, données personnelles), parfois en écriture. Les programmes la classent généralement P1 ou P2.
Ce qui distingue un rapport payé d'un rapport fermé :
| Rapport fermé ou dévalué | Rapport payé |
|---|---|
| « Le paramètre renvoie une erreur 500 avec une apostrophe » | Démonstration reproductible d'un changement de logique contrôlé (vrai/faux, délai) |
| Sortie brute d'un scanner sans analyse | Requêtes minimales et explication du contexte d'injection |
| Extraction de tables entières « pour prouver l'impact » | Preuve minimale : version du SGBD ou nom de l'utilisateur courant |
| Test qui a ralenti la production pendant des heures | Quelques requêtes espacées, arrêt dès la preuve obtenue |
Une erreur SQL affichée n'est pas une injection prouvée : c'est un signal. Le triage attend la démonstration que tu contrôles la syntaxe de la requête.
Le mécanisme
Une requête SQL est un petit programme. Lorsque le développeur la construit par concaténation de chaînes, la valeur saisie par l'utilisateur est collée dans le texte de ce programme avant que le SGBD ne l'analyse. Le parseur du SGBD ne sait pas où s'arrêtait l'intention du développeur : il voit un seul texte et le découpe en jetons (mots-clés, identifiants, littéraux, opérateurs). Si la valeur contient un métacaractère du langage SQL, comme une apostrophe qui ferme un littéral, elle sort de son contexte de donnée et devient du code.
C'est la cause racine, et elle est unique : le mélange du plan de contrôle et du plan de données. Tout le reste (techniques d'exploitation, contournements de filtres) n'est qu'une conséquence.
Le schéma illustre le cas d'école du contournement d'authentification. Retiens surtout l'étape 3 : la requête change de structure. Une requête préparée empêche exactement cela, car la structure est envoyée et compilée avant que la valeur ne soit transmise, dans un canal séparé.
Les conséquences dépendent de ce que la requête permet et des droits du compte SGBD utilisé par l'application :
- Lecture de données hors du périmètre prévu (autres utilisateurs, autres tables).
- Contournement de logique (authentification, contrôles de propriété).
- Écriture si la requête est un
UPDATE/INSERTou si le pilote autorise les requêtes empilées. - Effets sur le système dans les configurations très permissives, à ne jamais tester sans autorisation explicite.
Question de réflexion : pourquoi « échapper les apostrophes » n'est-il pas une correction suffisante ?
L'échappement dépend du contexte. Dans un contexte numérique (WHERE id = 42), il n'y a aucune apostrophe à fermer : une valeur comme 42 OR 1=1 modifie la logique sans contenir le moindre caractère échappé. L'échappement dépend aussi du jeu de caractères de la connexion (des confusions d'encodage multi-octets ont historiquement permis de neutraliser l'antislash ajouté) et ne s'applique pas aux identifiants (noms de colonnes, sens de tri). La requête préparée, elle, sépare structurellement code et données quel que soit le contexte de valeur.
Où chercher
La surface d'attaque est toute donnée qui finit dans une requête. Les plus fréquentes en bug bounty :
- Recherche, filtres, tri et pagination : paramètres
sort,order,orderBy,dir,filter[...],limit,offset. Le tri est le grand classique, car les noms de colonnes ne peuvent pas être paramétrés. - Exports et rapports (CSV, PDF, tableaux de bord analytiques) qui construisent des requêtes complexes à la volée.
- Corps JSON et GraphQL : les valeurs imbriquées sont parfois injectées dans des requêtes brutes, notamment dans les opérateurs de filtre génériques.
- En-têtes HTTP journalisés ou exploités côté serveur :
User-Agent,Referer,X-Forwarded-For, en-têtes de langue ou de locataire (X-Tenant-Id). - Cookies contenant des identifiants de préférence, de panier ou de suivi.
- Données stockées puis réutilisées (nom de profil, nom de fichier, adresse) : terrain de l'injection de second ordre.
- Anciens points de terminaison : versions d'API dépréciées (
/api/v1/), pages d'administration oubliées, applications mobiles plus anciennes.
Signaux faibles à repérer dans les réponses :
- Messages d'erreur du SGBD ou du pilote (fragments de syntaxe, nom de pilote, trace de pile).
- Une erreur générique 500 qui apparaît uniquement avec un caractère de syntaxe SQL et disparaît quand tu le doubles (
''). - Une différence de contenu, de taille ou de nombre de résultats entre deux valeurs logiquement équivalentes.
- Un paramètre de tri qui accepte des valeurs inattendues sans erreur de validation.
Méthode de test
Travaille toujours dans le périmètre autorisé, avec tes propres comptes de test, et lis la section « règles » du programme : beaucoup interdisent explicitement les outils d'injection automatisés.
1. Cartographier. Navigue dans l'application avec ton proxy d'interception (Burp Suite, Caido, ZAP) et note chaque paramètre susceptible d'être utilisé dans une requête. Classe-les par contexte probable : chaîne, numérique, identifiant (tri, colonne).
2. Établir une référence. Rejoue la requête légitime plusieurs fois pour mesurer la variabilité naturelle (taille de réponse, temps moyen). Sans référence, toute comparaison est fragile.
3. Détecter par les erreurs. Introduis un caractère de syntaxe, puis sa forme neutralisée :
GET /api/products?category=livres' HTTP/1.1
Host: shop.techshop.test
GET /api/products?category=livres'' HTTP/1.1
Host: shop.techshop.testSi la première provoque une erreur et la seconde revient au comportement normal, l'apostrophe est interprétée comme de la syntaxe. C'est un indice fort, pas encore une preuve.
4. Confirmer par l'inférence booléenne. Compare deux conditions, l'une toujours vraie, l'autre toujours fausse, qui ne changent rien d'autre :
GET /api/products?id=42 AND 1=1 HTTP/1.1
Host: shop.techshop.test
GET /api/products?id=42 AND 1=2 HTTP/1.1
Host: shop.techshop.testSi la première renvoie le produit 42 et la seconde une liste vide, tu contrôles la logique de la clause WHERE. Fais la contre-épreuve avec une expression arithmétique : si id=43-1 renvoie le produit 42, l'entrée est évaluée par le SGBD.
5. Confirmer par le délai (si rien n'est visible). Lorsque la réponse est identique dans tous les cas (injection « aveugle »), on peut injecter une condition qui, si elle est vraie, appelle la fonction d'attente du SGBD pendant un temps court (quelques secondes). Règles impératives : une seule requête à la fois, un délai bref, plusieurs répétitions alternant vrai et faux pour exclure la latence réseau, et jamais en boucle. Un délai proportionnel à la valeur demandée (2 s puis 4 s) est une preuve solide.
6. Identifier le contexte exact. Chaîne, numérique, clause ORDER BY, LIKE, IN (...) : chacun impose une syntaxe différente et conditionne la correction que tu recommanderas.
7. Obtenir la preuve minimale et s'arrêter (voir plus bas).
Pour le tri, un test inoffensif consiste à comparer sort=name avec sort=1 puis sort=2 (tri par position de colonne), puis une valeur conditionnelle. Si l'ordre des résultats change selon une condition que tu contrôles, l'injection est démontrée sans lire aucune donnée.
Variantes et défenses courantes
Contexte numérique. La valeur n'est pas entourée d'apostrophes : aucun échappement de chaîne ne protège. Un typage strict (conversion en entier) ou une requête préparée est la seule réponse fiable.
Clause ORDER BY et identifiants. Les marqueurs de paramètre (?, :nom) ne remplacent que des valeurs, jamais des noms de colonnes, de tables ou des mots-clés comme ASC/DESC. Les développeurs concatènent donc souvent ces éléments, même dans une base de code par ailleurs paramétrée. La seule défense correcte est une allow-list côté serveur qui associe une clé publique à un identifiant interne fixe.
Injection de second ordre. La valeur est stockée sans problème (via une requête préparée), puis relue plus tard et concaténée dans une autre requête, par exemple lors d'un traitement par lot ou d'un changement de mot de passe. Le point d'injection et le point de déclenchement sont séparés dans le temps, ce qui la rend invisible aux scanners. Pour la tester, enregistre une valeur contenant un caractère de syntaxe dans ton propre profil de test, puis déclenche les fonctionnalités qui réutilisent cette donnée et observe les erreurs ou changements de comportement.
Injection dans le JSON et les en-têtes. Un corps JSON n'est pas une protection : la valeur est désérialisée puis peut être concaténée comme n'importe quelle chaîne. Les en-têtes enregistrés en base (journalisation d'audit, statistiques) sont souvent oubliés par les revues de code.
Le WAF n'est pas une correction. Un pare-feu applicatif bloque des motifs connus et réduit le bruit des attaques opportunistes, mais il travaille sur la requête HTTP alors que la vulnérabilité réside dans la construction de la requête SQL. Tout filtrage par liste noire est incomplet par nature. Si un WAF bloque certains tests, signale-le, mais rappelle que la cause racine reste présente dans le code.
Question de réflexion : un ORM rend-il l'application immunisée ?
Non. L'ORM paramètre les requêtes qu'il génère, mais presque tous offrent des échappatoires : requêtes brutes, fragments de clause « where » sous forme de chaîne, tri par nom de colonne fourni par l'utilisateur, opérateurs de filtre dynamiques. Une injection dans une application utilisant un ORM se trouve presque toujours dans l'un de ces points. En revue de code, cherche les fonctions de type « raw », les interpolations de chaînes dans les appels de l'ORM et les paramètres de tri.
Prouver l'impact sans nuire
L'injection SQL est la vulnérabilité où la tentation d'« en montrer plus » est la plus forte, et la plus dangereuse juridiquement. Les programmes acceptent comme preuve suffisante :
- La démonstration booléenne ou temporelle reproductible décrite plus haut.
- Une seule information technique non sensible : la version du SGBD ou le nom de l'utilisateur SGBD courant (fonctions natives de type
version()oucurrent_user), éventuellement le nom de la base courante.
Sont interdits, sauf autorisation écrite du programme :
- Lire des données métier, même « juste une ligne » de la table des utilisateurs.
- Énumérer le schéma complet (liste de toutes les tables et colonnes).
- Toute requête modifiante (
INSERT,UPDATE,DELETE, création d'objets) ou empilée. - Les fonctions d'accès au système de fichiers ou d'exécution de commandes.
- Les délais longs ou répétés qui dégradent le service.
Les outils automatisés (sqlmap et équivalents) méritent une mention à part : à niveau de risque élevé, ils peuvent toucher des UPDATE ou DELETE, envoyer des milliers de requêtes et provoquer des verrous. Beaucoup de programmes les interdisent. S'ils sont autorisés, cible un seul paramètre, au risque minimal, avec un débit plafonné. La confirmation manuelle reste plus sûre et plus convaincante.
Dès la preuve obtenue, arrête les tests sur ce point et rédige le rapport. Si tu tombes par accident sur des données personnelles, ne les conserve pas et mentionne-le au programme.
Évaluer la sévérité
La sévérité dépend surtout de l'authentification requise et des droits du compte SGBD. Une preuve minimale suffit au triage pour estimer l'impact : tu décris ce que le compte pourrait faire, sans le faire.
| Scénario | Vecteur CVSS 3.1 | Score |
|---|---|---|
| Injection non authentifiée, compte SGBD avec droits de lecture et d'écriture | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H | 9.8 (Critique) |
| Injection aveugle non authentifiée, compte limité à la lecture | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N | 7.5 (Élevée) |
| Injection dans la recherche d'un espace client (compte standard requis), droits complets | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H | 8.8 (Élevée) |
| Injection dans un écran réservé aux administrateurs, lecture seule | AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N | 4.9 (Moyenne) |
Justifie chaque métrique dans le rapport. Par exemple, I:H n'est défendable que si tu as établi (par la nature de la requête ou les droits du compte) qu'une écriture est possible, sans l'avoir exécutée.
Corriger
Code vulnérable (Node.js) : la valeur et l'identifiant de tri sont concaténés.
// VULNÉRABLE
app.get("/api/products", async (req, res) => {
const { category, sort } = req.query;
const sql =
"SELECT id, name, price FROM products WHERE category = '" +
category + "' ORDER BY " + sort;
const rows = await db.query(sql);
res.json(rows);
});Code corrigé : requête préparée pour la valeur, allow-list pour l'identifiant.
// CORRIGÉ
const SORTABLE = new Map([
["name", "name"],
["price", "price"],
["recent", "created_at"],
]);
app.get("/api/products", async (req, res) => {
const category = String(req.query.category ?? "");
const column = SORTABLE.get(req.query.sort) ?? "name";
const direction = req.query.dir === "desc" ? "DESC" : "ASC";
const sql =
"SELECT id, name, price FROM products WHERE category = $1 " +
"ORDER BY " + column + " " + direction;
const rows = await db.query(sql, [category]);
res.json(rows);
});La colonne et le sens de tri proviennent désormais exclusivement de constantes du code : la donnée utilisateur ne sert plus qu'à choisir parmi des valeurs sûres.
Défense en profondeur :
- Moindre privilège : le compte SGBD de l'application ne doit avoir que les droits nécessaires (pas de droits d'administration, pas d'accès aux fichiers, comptes séparés pour la lecture et l'écriture si possible).
- ORM bien utilisé : bannir ou encadrer les requêtes brutes, revue de code systématique des interpolations.
- Validation des entrées par type (entier, énumération), en complément et jamais à la place du paramétrage.
- Messages d'erreur génériques côté client, détails uniquement dans les journaux serveur.
- Désactivation des requêtes empilées au niveau du pilote quand c'est possible.
- Tests automatisés (SAST, revue des fonctions « raw ») dans la chaîne d'intégration continue.
- Le WAF reste une couche de détection, pas un correctif.
Erreurs qui font rejeter un rapport
- Signaler une simple erreur 500 déclenchée par une apostrophe, sans démonstration de contrôle de la logique.
- Coller la sortie d'un scanner sans reproduction manuelle ni explication du contexte.
- Extraire des données pour « prouver l'impact » : c'est une violation des règles, souvent sanctionnée par une exclusion du programme.
- Confondre latence réseau et injection temporelle faute de mesures répétées et de contre-épreuve.
- Utiliser un outil automatisé interdit ou à un niveau de risque élevé sur la production.
- Surévaluer la sévérité (par exemple
I:HetA:Hsans justification) au lieu d'argumenter métrique par métrique.
Checklist du hunter
- Périmètre vérifié, règles relues (outils automatisés autorisés ou non).
- Paramètres classés par contexte : chaîne, numérique, identifiant de tri.
- Référence mesurée (taille, temps) avant tout test.
- Paire erreur / neutralisation (
'puis'') testée. - Confirmation booléenne ou temporelle reproductible, avec contre-épreuve.
- En-têtes, cookies, JSON et données stockées (second ordre) testés.
- Preuve minimale : version du SGBD ou utilisateur courant, rien de plus.
- Arrêt immédiat, rapport avec requêtes exactes, contexte et correction recommandée.
Pour aller plus loin
- OWASP Cheat Sheet Series — SQL Injection Prevention Cheat Sheet.
- OWASP Cheat Sheet Series — Query Parameterization Cheat Sheet.
- OWASP WSTG v4.2 — WSTG-INPV-05 « Testing for SQL Injection ».
- PortSwigger Web Security Academy — SQL injection (dont « Blind SQL injection »).
- MITRE CWE-89 — Improper Neutralization of Special Elements used in an SQL Command.
- FIRST — Common Vulnerability Scoring System v3.1 : Specification Document.