Introduction

Au cours d’un audit de sécurité de plugins WordPress, nous avons découvert une vulnérabilité d’injection SQL dans REDACTED(versions <= 2.5), un plugin qui permet aux administrateurs de générer et d’exécuter des requêtes SQL à partir du langage naturel.

Le plugin implémente une fonction de filtrage SQL personnalisée REDACTED_is_safe_query() conçue pour garantir que seules les requêtes SELECT peuvent être exécutées. Le filtre supprime les commentaires, impose un préfixe SELECT, bloque les multi-requêtes et vérifie une liste noire de mots-clés dangereux incluant UNION.

Sur le papier, cela semble solide. En pratique, un défaut fondamental dans la façon dont le filtre traite les commentaires MySQL rend toute la protection trivialement contournable.

Cet article se concentre sur la technique de contournement elle-même : pourquoi le filtrage SQL basé sur les regex échoue face aux commentaires conditionnels MySQL, et pourquoi cette classe de vulnérabilité continue d’apparaître dans la nature.

Le filtre : ce qu’il fait

Voici la fonction de filtre complète :

private function REDACTED_is_safe_query($query) {
    // Remove comments
    $query = preg_replace('!/\*.*?\*/!s', '', $query);  // block comments
    $query = preg_replace('/--.*?\n/', '', $query);     // line comments
    $query = trim($query);

    // Allow whitespace/comments before SELECT
    if (!preg_match('/^\s*SELECT\s/i', $query)) {
        return false;
    }

    // Block multi-queries
    if (preg_match('/;\s*\S/', $query)) {
        return false;
    }

    // Remove quoted strings to avoid false positives
    $query_no_strings = preg_replace("/'(?:''|[^'])*'/", '', $query);
    $query_no_strings = preg_replace('/"(?:[^"\\]|\\.)*"/', '', $query_no_strings);

    // Block forbidden keywords as SQL commands only
    $forbidden_keywords = array(
        'UPDATE', 'INSERT', 'DELETE', 'ALTER', 'DROP', 'TRUNCATE', 'CREATE', 'REPLACE',
        'GRANT', 'REVOKE', 'LOCK', 'UNLOCK', 'EXEC', 'EXECUTE', 'CALL', 'UNION'
    );
    foreach ($forbidden_keywords as $keyword) {
        if (preg_match('/\b' . $keyword . '\b/i', $query_no_strings)) {
            return false;
        }
    }
    return true;
}

La logique se décompose en cinq étapes séquentielles :

Étape Objectif Implémentation
1 Supprimer les commentaires de bloc preg_replace('!/\*.*?\*/!s', '', $query)
2 Supprimer les commentaires de ligne preg_replace('/--.*?\n/', '', $query)
3 Imposer le préfixe SELECT preg_match('/^\s*SELECT\s/i', $query)
4 Bloquer les multi-requêtes preg_match('/;\s*\S/', $query)
5 Bloquer les mots-clés dangereux Boucle sur la liste noire avec correspondance de limites de mots \b...\b

Le développeur a clairement compris le modèle de menace : empêcher UNION SELECT, INSERT, DELETE, DROP, etc. La liste de mots-clés est complète. La suppression des chaînes évite les faux positifs sur des valeurs comme WHERE name = 'UNION STREET'.

Alors où se situe la faille ?

Commentaires conditionnels MySQL : l’écart entre PHP et MySQL

MySQL prend en charge une syntaxe de commentaire non standard appelée commentaires conditionnels (également connus sous le nom de commentaires de version) :

/*!UNION*/
/*!50000SELECT*/

La syntaxe est /*! ... */ ou /*!NNNNN ... */NNNNN est un numéro de version MySQL. Le SQL standard traite tout ce qui se trouve entre /* et */ comme un commentaire et l’ignore. MySQL traite /*! ... */ comme du code exécutable, contrairement aux commentaires SQL standard.

Cela crée une déconnexion fondamentale :

Analyseur Voit /*!UNION*/ comme…
Regex PHP (/\*.*?\*/) Un commentaire → le supprime
Moteur MySQL SQL exécutable → l’exécute

L’étape 1 du filtre supprime tous les commentaires de bloc en utilisant !/\*.*?\*/!s. Cette regex ne fait pas de distinction entre les commentaires standard (/* ignored */) et les commentaires conditionnels (/*!executed*/). Elle supprime les deux.

Le contournement : étape par étape

Considérez ce payload :

SELECT 1,2,3 FROM wp_options WHERE 1=0 /*!UNION*/ /*!SELECT*/ user_login,user_pass,user_email FROM wp_users LIMIT 10

Parcourons chaque étape du filtre :

Étape 1 — Supprimer les commentaires de bloc

La regex !/\*.*?\*/!s correspond à /*!UNION*/ et /*!SELECT*/ comme des commentaires de bloc et les supprime :

Avant : SELECT 1,2,3 FROM wp_options WHERE 1=0 /*!UNION*/ /*!SELECT*/ user_login,user_pass,user_email FROM wp_users LIMIT 10
Après : SELECT 1,2,3 FROM wp_options WHERE 1=0   user_login,user_pass,user_email FROM wp_users LIMIT 10

Les mots-clés UNION et SELECT ont été effacés de la chaîne que le filtre va analyser.

Étape 2 — Supprimer les commentaires de ligne

Aucun commentaire -- présent. Aucun changement.

Étape 3 — Imposer le préfixe SELECT

La requête nettoyée commence par SELECT. Contrôle réussi.

Étape 4 — Bloquer les multi-requêtes

Aucun ; suivi d’un caractère non-blanc. Contrôle réussi.

Étape 5 — Bloquer les mots-clés dangereux

Le filtre itère sur la liste noire et exécute \bUNION\b contre la chaîne nettoyée. Le mot UNION n’existe plus dans la chaîne. Contrôle réussi.

REDACTED_is_safe_query() retourne true.

Ce que MySQL voit

Le payload original (non modifié) atteint $wpdb->get_results() :

SELECT 1,2,3 FROM wp_options WHERE 1=0 /*!UNION*/ /*!SELECT*/ user_login,user_pass,user_email FROM wp_users LIMIT 10

MySQL interprète /*!UNION*/ et /*!SELECT*/ comme du code exécutable. La requête finale exécutée est :

SELECT 1,2,3 FROM wp_options WHERE 1=0 UNION SELECT user_login,user_pass,user_email FROM wp_users LIMIT 10

Résultat : extraction complète des identifiants.

Pourquoi ce modèle est fondamentalement cassé

Ce n’est pas une simple erreur qui peut être corrigée en améliorant la regex. Le problème est architectural.

Le problème du double analyseur

Tout filtre SQL qui opère en PHP (ou tout autre langage) avant de passer la requête à MySQL doit répliquer parfaitement le comportement d’analyse de MySQL. L’analyseur de MySQL a des centaines de cas particuliers :

  • Commentaires conditionnels : /*!UNION*/, /*!50000UNION*/
  • Échappement par backslash dans les chaînes (dépendant du mode) : 'it\'s'
  • Introducteurs de jeux de caractères : _utf8'payload'
  • Citation d’identifiants : `UNION` dans certains contextes
  • Troncature de caractères multi-octets

Un filtre basé sur les regex n’atteindra jamais la parité avec l’analyseur réel de MySQL. Chaque cas particulier où le filtre PHP et MySQL ne sont pas d’accord est un vecteur de contournement potentiel.

L’anti-pattern supprimer-puis-vérifier

L’architecture du filtre crée un cycle auto-défait :

Étape 1 : Supprimer les constructions dangereuses (commentaires)  ← supprime l'enveloppe
Étape 5 : Vérifier les mots-clés dangereux                        ← le mot-clé a déjà disparu

L’étape 1 a été conçue pour empêcher l’obfuscation basée sur les commentaires comme SELECT /* junk */ DROP TABLE. Mais elle neutralise par inadvertance l’étape 5 en supprimant l’enveloppe même qui cache le mot-clé de la détection tout en le préservant pour l’exécution MySQL.

Le contexte d’exploitation

Dans REDACTED, le point d’entrée vulnérable est l’action AJAX REDACTED_execute_sql_query. Le paramètre sql_query est lu directement depuis $_POST, passé à travers REDACTED_is_safe_query(), puis exécuté :

$sql_query = isset($_POST['sql_query']) ? wp_unslash($_POST['sql_query']) : '';
// ...
if (!$this->REDACTED_is_safe_query($sql_query)) {
    wp_send_json_error(['message' => 'Seules les requêtes SELECT sont autorisées.']);
    exit;
}
// ...
$results = $wpdb->get_results($sql_query, ARRAY_A);

L’appel wp_unslash() n’est pas une fonction d’assainissement, il inverse les guillemets magiques de WordPress. La requête atteint ensuite $wpdb->get_results() sans aucune requête préparée.

Un administrateur authentifié peut extraire n’importe quelle donnée de la base de données : identifiants WordPress (wp_users), clés secrètes (wp_options), données clients, ou énumérer tout le schéma via information_schema.

Conclusion

La fonction REDACTED_is_safe_query() représente une approche bien intentionnée mais fondamentalement défaillante de la prévention des injections SQL. Le développeur a identifié les bonnes menaces et construit un filtre multi-couches. Mais parce que les regex PHP et l’analyseur de MySQL interprètent différemment la même syntaxe, le filtre se défait lui-même : son étape de suppression des commentaires retire les preuves dont son étape de vérification des mots-clés a besoin pour détecter l’attaque.

Les commentaires conditionnels MySQL ne sont pas nouveaux — ils ont été documentés dans les contextes d’injection SQL depuis plus d’une décennie. Pourtant, ils continuent de contourner les filtres personnalisés parce que le modèle sous-jacent — “désinfecter avec des regex, puis exécuter du SQL brut” — continue d’apparaître dans le code de production.

Conclusion de la conclusion : ne jamais filtrer SQL avec des regex. Utilisez des requêtes préparées, ou laissez le moteur de base de données faire sa propre analyse.

Leave a Reply

Your email address will not be published. Required fields are marked *