Introduction

During a security audit of WordPress plugins, we discovered a SQL injection vulnerability in REDACTED (versions <= 2.5), a plugin that lets administrators generate and execute SQL queries from natural language.

The plugin implements a custom SQL filter function REDACTED_is_safe_query() designed to ensure only SELECT queries can be executed. The filter strips comments, enforces a SELECT prefix, blocks multi-queries, and checks for a blacklist of dangerous keywords including UNION.

On paper, it looks solid. In practice, a fundamental flaw in how the filter processes MySQL comments makes the entire protection trivially bypassable.

This article focuses on the bypass technique itself: why regex-based SQL filtering fails against MySQL conditional comments, and why this class of vulnerability keeps appearing in the wild.

The Filter: What It Does

Here is the complete filter function:

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;
}

The logic breaks down into five sequential steps:

Step Purpose Implementation
1 Strip block comments preg_replace('!/\*.*?\*/!s', '', $query)
2 Strip line comments preg_replace('/--.*?\n/', '', $query)
3 Enforce SELECT prefix preg_match('/^\s*SELECT\s/i', $query)
4 Block multi-queries preg_match('/;\s*\S/', $query)
5 Block dangerous keywords Loop over blacklist with \b...\b word boundary matching

The developer clearly understood the threat model: prevent UNION SELECT, INSERT, DELETE, DROP, etc. The keyword list is comprehensive. The string stripping avoids false positives on values like WHERE name = 'UNION STREET'.

So where does it break?

MySQL Conditional Comments: The Gap Between PHP and MySQL

MySQL supports a non-standard comment syntax called conditional comments (also known as version comments):

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

The syntax is /*! ... */ or /*!NNNNN ... */ where NNNNN is a MySQL version number. Standard SQL treats anything between /* and */ as a comment and ignores it. MySQL treats /*! ... */ as executable code, unlike standard SQL comments.

This creates a fundamental disconnect:

Parser Sees /*!UNION*/ as…
PHP regex (/\*.*?\*/) A comment → strips it
MySQL engine Executable SQL → runs it

The filter’s Step 1 removes all block comments using !/\*.*?\*/!s. This regex does not distinguish between standard comments (/* ignored */) and conditional comments (/*!executed*/). It strips both.

The Bypass: Step by Step

Consider this payload:

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

Let’s walk through each step of the filter:

Step 1 — Strip block comments

The regex !/\*.*?\*/!s matches /*!UNION*/ and /*!SELECT*/ as block comments and removes them:

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

The UNION and SELECT keywords have been erased from the string the filter will analyze.

Step 2 — Strip line comments

No -- comments present. No change.

Step 3 — Enforce SELECT prefix

The cleaned query starts with SELECT. Check passes.

Step 4 — Block multi-queries

No ; followed by a non-whitespace character. Check passes.

Step 5 — Block dangerous keywords

The filter iterates over the blacklist and runs \bUNION\b against the cleaned string. The word UNION no longer exists in the string. Check passes.

REDACTED_is_safe_query() returns true.

What MySQL Sees

The original (unmodified) payload reaches $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 interprets /*!UNION*/ and /*!SELECT*/ as executable code. The final query executed is:

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

Result: full credential dump.

Why This Pattern Is Fundamentally Broken

This is not a minor oversight that can be fixed by improving the regex. The problem is architectural.

The dual-parser problem

Any SQL filter that operates in PHP (or any language) before passing the query to MySQL must perfectly replicate MySQL’s parsing behavior. MySQL’s parser has hundreds of edge cases:

  • Conditional comments: /*!UNION*/, /*!50000UNION*/
  • Backslash escaping in strings (mode-dependent): 'it\'s'
  • Character set introducers: _utf8'payload'
  • Identifier quoting: `UNION` in certain contexts
  • Multi-byte character truncation

A regex-based filter will never achieve parity with MySQL’s actual parser. Every edge case where the PHP filter and MySQL disagree is a potential bypass vector.

The strip-then-check anti-pattern

The filter’s architecture creates a self-defeating cycle:

Step 1: Strip dangerous constructs (comments)  ← removes the wrapper
Step 5: Check for dangerous keywords           ← keyword is already gone

Step 1 was designed to prevent comment-based obfuscation like SELECT /* junk */ DROP TABLE. But it inadvertently neutralizes Step 5 by removing the very wrapper that hides the keyword from detection while preserving it for MySQL execution.

The Exploitation Context

In REDACTED, the vulnerable endpoint is the AJAX action REDACTED_execute_sql_query. The sql_queryparameter is read directly from $_POST, passed through REDACTED_is_safe_query(), then executed:

$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);

The wp_unslash() call is not a security sanitizer — it reverses WordPress’s magic quotes. The query then hits $wpdb->get_results() without any prepared statement.

An authenticated administrator can extract any data from the database: WordPress credentials (wp_users), secret keys (wp_options), – customer data, or enumerate the entire schema via information_schema.

Conclusion

The REDACTED_is_safe_query() function represents a well-intentioned but fundamentally flawed approach to SQL injection prevention. The developer identified the right threats and built a multi-layered filter. But because PHP regex and MySQL’s parser interpret the same syntax differently, the filter defeats itself: its comment-stripping step removes the evidence that its keyword-checking step needs to detect the attack.

MySQL conditional comments are not new — they have been documented in SQL injection contexts for over a decade. Yet they continue to bypass custom filters because the underlying pattern — “sanitize with regex, then execute raw SQL” — keeps appearing in production code.

Conclusion of the conclusion: never filter SQL with regex. Use prepared statements, or let the database engine do its own parsing.

Leave a Reply

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