The Exploit
An unauthenticated attacker can inject time-based SQL queries via the search POST parameter in the Member Directory widget to exfiltrate database contents without authentication.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=um_member_directory_get_results&search=test' UNION SELECT SLEEP(5),2,3,4,5,6,7,8,9,10 FROM wp_users WHERE '1'='1
The server responds after a 5-second delay, confirming the injected SLEEP() function executed within the SQL query. By timing responses across multiple payloads, an attacker enumerates table structure, column names, and sensitive data (password hashes, email addresses, user metadata) from the WordPress database without any credentials or capability checks.
What the Patch Did
Before
// Line 1730 in prepare_search()
return $search;
// Lines 1879-1880 in general_search()
$sql['where'] = preg_replace(
'/(' . $meta_join_for_search . '.meta_value = \'' . str_replace( '/', '\/', wp_slash( $search ) ) . '\')/im',
After
// Line 1733 in prepare_search()
return esc_sql( $search );
// Lines 1879-1882 in general_search()
$pattern = $wpdb->prepare( $meta_join_for_search . '.meta_value = %s', $search );
$pattern = '/(' . str_replace( '/', '\/', wp_slash( $pattern ) ) . ')/im';
$sql['where'] = preg_replace(
$pattern,
The patch introduced two sequential defences: (1) esc_sql() escapes special SQL characters in the return value of prepare_search(), treating single quotes, backslashes, and other metacharacters as literal strings; (2) $wpdb->prepare() with the %s placeholder parameterizes the query construction before regex substitution, ensuring the user input is bound as data rather than executable SQL syntax. Together these controls form a defence-in-depth layer against concatenation-based injection.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
The search parameter arrives via $_POST['search'] in the AJAX handler and flows into prepare_search() at line 1730, which returned it without escaping. The return value then reached general_search() where it was concatenated directly into a regex pattern and subsequently embedded into a SQL WHERE clause via preg_replace(). The developer attempted a shallow defence with regex patterns checking for SELECT, UPDATE, and DELETE keywords, but these were case-insensitive and easily bypassed by whitespace injection (SEL ECT), alternate syntax (UNION SELECT), or time-based attacks using function calls like SLEEP() that don't match the block list. No parameterization occurred at the SQL sink, allowing quote-enclosed payloads to close the existing string literal and inject arbitrary SQL logic.
Why It Works
The load-bearing line is $wpdb->prepare( $meta_join_for_search . '.meta_value = %s', $search ). This line uses WordPress's parameterized query builder to separate SQL structure from data. The %s placeholder tells $wpdb->prepare() to escape the second argument according to the database driver's rules—converting single quotes to escaped sequences (\' in MySQL), ensuring that even if an attacker submits ' OR '1'='1, it becomes the literal string value \' OR \'1\'=\'1 and cannot alter the query's logical structure. The esc_sql() call on line 1733 is a second-order defence that catches the output of prepare_search() before it reaches the regex pattern, preventing bypass through direct string concatenation. The enhanced regex block list (adding SLEEP, DATABASE, WHERE patterns) is a third-order control that stops common blind-SQL techniques, but it is not sufficient alone—removing it would allow SLEEP(5) payloads to pass through, even with $wpdb->prepare() in place, because the regex runs before the parameterization step in the original code path. The patch layers all three: early escaping, parameterized binding, and pattern rejection.
Hardening Checklist
- Use
$wpdb->prepare()with placeholders (%s,%d,%i) for all user-controlled values in SQL queries, not manual string concatenation orwp_slash(). This is the primary control and should be the first line of defence in every query. - Never rely on regex or allowlist/blocklist patterns to sanitize SQL keywords. Regex cannot reliably distinguish between SQL syntax and data; parameterization renders keyword filtering obsolete and prevents false sense of security.
- Apply
esc_sql()to any variable returned from a custom function that processes user input, particularly in helper functions likeprepare_search()that may be called from multiple contexts. Assume downstream code will not re-escape. - Escape output at the point of use, not at the point of receipt. If
$searchis used in multiple sinks (regex, SQL, HTML), apply the appropriate escaping function (esc_sql(),preg_quote(),esc_attr()) at each sink, not once at input. - Audit all AJAX handlers registered with
add_action( 'wp_ajax_nopriv_*' )for authentication checks and parameterized queries. Unauthenticated AJAX endpoints are common injection vectors; review them separately from authenticated admin handlers.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-0308
- Ultimate Member plugin changelog: https://www.ultimatemember.com/