Comment les requêtes préparées (prepared statement) protègent-elles contre les injections SQL ?
04 mars 2025
Les injections SQL sont des vulnérabilités largement connues et souvent critiques, mais elles demeurent encore très répandues dans les applications web actuelles. La principale protection contre cette vulnérabilité repose sur l'utilisation des requêtes préparées, mais comment fonctionnent-elles exactement ?
Les exemples de code présentés utilisent PHP et l'extension MySQLi, mais les mêmes principes s'appliquent à d'autres langages.
Fonctionnement d'une requête directe
Exemple d'une injection SQL avec query()
Avant de plonger dans le fonctionnement des requêtes préparées, il est essentiel de rappeler ce qu'est une injection SQL et comment elle se produit. Une injection SQL survient lorsqu'une donnée fournie par l'utilisateur (ou toute autre source dite "non fiable") est insérée directement dans une requête SQL, sans validation ni assainissement préalable.
Un exemple de cette vulnérabilité pourrait être une fonctionnalité permettant à l'utilisateur d'accéder aux informations de différentes villes de France. Lorsqu'un utilisateur consulte les informations d'une ville, la requête GET suivante est envoyée, précisant le nom de la ville :
GET /city?name=paris HTTP/1.1$city_name = $_GET['name'];
$sql = "SELECT name, population FROM cities WHERE name = '$city_name'";
$result = $cnx->query($sql);L'injection est relativement simple. La tautologie permet de récupérer l'ensemble des enregistrements de la table, tandis que l'utilisation de l'UNION permet d'extraire des informations provenant d'autres tables :
GET /city?name=' OR 1=1# HTTP/1.1
City: Paris
Population: 2148327
City: Marseille
Population: 861635
City: Lyon
Population: 513275
City: Toulouse
Population: 479175Mais comment, et pourquoi, l'injection SQL se produit-elle réellement dans le code, notamment au niveau de l'extension MySQLi de PHP ?
Analyse du code PHP lors de l'exécution d'une requête directe
Lors de l'appel à la méthode PHP query(), la fonction mysqli_query() (qui est un alias défini dans ext/mysqli/mysqli.stub.php) présente dans le fichier ext/mysqli/mysqli_nonapi.c est exécutée :
Pour commencer, plusieurs vérifications sont effectuées, notamment celle des arguments passés à la méthode query() à l'aide de l'appel à zend_parse_method_parameters(), dont le code est présent dans le fichier Zend/zend_API.c :
Ensuite, vient une seconde vérification qui consiste à s'assurer que la chaîne de caractères représentant la requête SQL n'est pas vide :
Puis une troisième vérification, concernant le mode de résultat retourné par le serveur MySQL (le second paramètre de la méthode mysqli::query() de PHP qui possède la valeur par défaut MYSQLI_STORE_RESULT) :
Puis finalement la requête SQL est exécutée (cas par défaut) via un appel à la méthode mysql_real_query() :
Cette méthode est définie en réalité par la méthode mysqlnd_query() ("mysqlnd" pour MySQL Native Driver) via une macro présente dans le fichier ext/mysqlnd/mysqlnd_libmysql_compat.h) :
Puis par une seconde macro présente dans le fichier ext/mysqlnd/mysqlnd.h :
Ce qui conduit à la fonction query() présente dans le fichier ext/mysqlnd/mysqlnd_connection.c :
Qui mène à l'exécution de la méthode send_query() présente dans le même fichier :
Puis à l'exécution de la méthode query() du fichier ext/mysqlnd/mysqlnd_commands.c :
Et finalement, à l'envoi de la commande grâce à la méthode send_command() présente dans le fichier ext/mysqlnd/mysqlnd_wireprotocol.c. Cette méthode effectuant un appel à PACKET_WRITE() pour transmettre le paquet (la requête SQL) au serveur MySQL :
La chaîne de caractères (const zend_uchar * const arg) , représentant la requête SQL renseignée par le développeur en tant que paramètre de la méthode query(), est envoyée sans aucun traitement particulier en matière de sécurité. Lors de l'exploitation de l'injection par un attaquant, le serveur MySQL recevra ainsi la chaîne de caractères suivante, sans possibilité de distinguer la requête originale des données potentiellement malveillantes fournies par l'attaquant :
Maintenant que le fonctionnement d'une requête directe est bien compris, il est possible de passer à l'analyse des requêtes préparées, et en particulier, d'examiner pourquoi elles offrent une protection contre les injections SQL.
Fonctionnement d'une requête préparée
Exemple d'une requête préparée avec prepare()
La correction de l'injection SQL du code présenté en introduction peut se faire en utilisant les requêtes préparées de la manière suivante :
Cette fois, la tentative d'injection ne fonctionne pas, et aucun résultat n'est retourné par l'application.
Il est cependant intéressant de noter qu'une utilisation incorrecte des requêtes préparées peut également conduire à une injection SQL :
Deux fonctions clés seront examinées dans la prochaine partie : la fonction prepare() ainsi que la fonction bind_param().
Analyse du code PHP lors de l'exécution d'une requête préparée
Analyse de la fonction prepare()
Lors de l'appel à la méthode PHP prepare(), la fonction mysqli_prepare(), qui est un alias défini dans ext/mysqli/mysqli.stub.php présente dans le fichier ext/mysqli/mysqli_api.c, est exécutée :
Après avoir passé les vérifications et la gestion des erreurs, les méthodes clés sont l'initialisation de la déclaration avec mysql_stmt_init() et sa préparation avec mysql_stmt_prepare().
La fonction mysql_stmt_init() est issue de mysqlnd sous le nom mysqlnd_stmt_init() définie dans le fichier ext/mysqlnd/mysqlnd_libmysql_compat.h :
Puis dans le fichier ext/mysqlnd/mysqlnd.h :
Cette fonction, présente dans le fichier ext/mysqlnd/mysqlnd_connection.c, crée simplement un objet MYSQLND_STMT , représentant une requête préparée associée à la connexion :
De la même façon, la fonction mysql_stmt_prepare() est définie dans le fichier ext/mysqlnd/mysqlnd_libmysql_compat.h :
Puis dans le fichier ext/mysqlnd/mysqlnd.h :
Cette fonction est définie dans le fichier ext/mysqlnd/mysqlnd_ps.c :
La première section intéressante est l'envoi de la commande au serveur MySQL via un appel à la fonction stmt_prepare(), présente dans le fichier ext/mysqlnd/mysqlnd_commands.c, de l'objet command :
Cet appel entraîne à son tour l'exécution de la fonction send_command(), précédemment analysée lors de l'étude des requêtes directes, et qui transmet la requête SQL au serveur MySQL en utilisant PACKET_WRITE():
En supposant une mauvaise utilisation des requêtes préparées, comme illustré au début de cette section, MySQL ne peut fournir aucune protection et l'application sera donc bel et bien vulnérable lors de l'appel à la méthode execute() :
La deuxième section importante concerne le traitement des paramètres de marque (parameter markers), représentés par le caractère "?", qui est géré par la méthode mysqlnd_stmt_read_prepare_response() présente dans le fichier ext/mysqlnd/mysqlnd_ps.c :
Cette méthode lit et traite la réponse du serveur MySQL après l'envoi de la commande de préparation, et récupère le nombre de paramètres de la requête, qui est ensuite stocké dans param_count de l'objet stmt. Une fois la requête préparée et le nombre de paramètres déterminé, il est possible de se concentrer sur l'analyse de la méthode bind_param().
Analyse de la fonction bind_param()
La méthode permettant de lier les paramètres est la fonction mysqli_stmt_bind_param() (via un alias défini dans ext/mysqli/mysqli.stub.php) présente dans le fichier ext/mysqli/mysqli_api.c dont le code est le suivant :
L'appel à la méthode MYSQLI_FETCH_RESOURCE_STMT() permet de récupérer l'élément stmt associé, c'est-à-dire la requête préparée. Puis deux vérifications sont effectuées :
Le nombre de paramètres fournis à
bind_param()doit correspondre à ceux définis par la chaîne de caractères (ex. à "ssi" doit correspondre à trois paramètres).Le nombre de paramètres fournis à
bind_param()doit correspondre à ceux déclarés à la préparation de la requête (les caractères "?").
Une fois ces vérifications effectuées, la liaison est effectuée grâce à la méthode mysqli_stmt_bind_param_do_bind() présente dans le même fichier :
L'appel à la fonction mysqlnd_stmt_alloc_param_bind() permet d'allouer de la mémoire afin de stocker les paramètres à lier. Elle est définie dans le fichier ext/mysqlnd/mysqlnd.h :
Puis dans ext/mysqlnd/mysqlnd_structs.h :
Son code est le suivant (mnd_ecalloc() permet d'allouer dynamiquement la mémoire nécessaire) :
Vient ensuite une vérification du type du paramètre à lier ("s" pour string, "i" pour integer, "d" pour double et "b" pour blob). Puis un appel à la méthode mysqlnd_stmt_bind_param() pour lier les paramètres préparés à la déclaration de la requête, qui, comme l'indique le fichier ext/mysqlnd/mysqlnd.h est en réalité un appel à bind_parameters() :
Le code de cette méthode est présent dans le fichier ext/mysqldn/mysqlnd_ps.c :
La fonction débute par une série de vérifications, mais c'est la ligne suivante qui établit le lien entre les paramètres fournis par le développeur à la méthode bind_param() et la déclaration de la requête SQL (stmt) :
Les paramètres sont maintenant liés à la requête. La fonction execute() prépare ces paramètres et les place dans un buffer dédié avant de les envoyer à MySQL. Lors de l'exécution de la requête, le serveur MySQL est capable de distinguer clairement entre la structure de la requête SQL et les valeurs des paramètres, qui sont traitées comme des données et non comme du code. Cette séparation garantit que même si les valeurs contiennent des caractères spéciaux, elles ne seront pas interprétées comme du code SQL, empêchant ainsi toute injection malveillante.
Conclusion
Les requêtes préparées constituent probablement l'une des méthodes les plus efficaces pour se protéger contre les injections SQL (les procédures stockées peuvent également être utilisées à cette fin). Cette protection résulte d'une séparation claire et sécurisée entre le code (la requête SQL) et les données, potentiellement non fiables, empêchant ainsi toute interférence entre les deux.
Mis à jour