The Exploit
An unauthenticated attacker can inject arbitrary SQL into the member directory sorting query by manipulating the sorting parameter. No authentication or admin access required.
GET /wp-admin/admin-ajax.php?action=um_get_members&sorting=u.ID%27%20UNION%20SELECT%201,user_login,3,4,5,6,7,8,9%20FROM%20wp_users%20WHERE%20%271%27=%271 HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
The attacker observes a response containing leaked user logins from the WordPress users table mixed into the member directory results, or observes timing delays consistent with boolean-based SQL blind injection if crafting conditional payloads. The database query executes attacker-supplied SQL in the ORDER BY clause context, allowing data exfiltration or conditional logic execution.
What the Patch Did
Before:
$custom_sort_type = apply_filters( 'um_member_directory_custom_sorting_type', $custom_sort_type, $sortby, $directory_data );
$this->sql_order = " ORDER BY CAST( umm_sort.um_value AS {$custom_sort_type} ) {$custom_sort_order} ";
After:
$custom_sort_type = apply_filters( 'um_member_directory_custom_sorting_type', $custom_sort_type, $sortby, $directory_data );
$custom_sort_type = esc_sql( $custom_sort_type );
$custom_sort_type = in_array( strtoupper( $custom_sort_type ), $this->sort_data_types, true ) ? $custom_sort_type : 'CHAR';
The patch introduced two defensive layers: first, esc_sql() escapes SQL special characters in the $custom_sort_type variable before it reaches the query string. Second, a whitelist check using in_array() with strict type comparison (true parameter) validates that the escaped value belongs to an allowed set of cast types (CHAR, SIGNED, UNSIGNED, DECIMAL, etc.). The combination ensures that even if a filter or earlier code path provides a malicious value, it is both escaped and validated against known-safe values.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
The $custom_sort_type variable is populated via apply_filters() from user-supplied directory configuration or sorting parameters, then interpolated directly into a SQL ORDER BY CAST() expression without escaping or validation. The vulnerable code path flows from the AJAX um_get_members action handler, through filter hooks that may receive attacker-controlled sort parameters, into the class-member-directory-meta.php query builder where the unescaped type specifier is concatenated into $this->sql_order. No sanitization or prepared statement parameter binding intervenes at the critical point where the string enters the SQL query context.
Why It Works
The load-bearing line is $custom_sort_type = esc_sql( $custom_sort_type ), which strips or escapes SQL metacharacters (single quotes, backslashes, double quotes) that would normally allow an attacker to break out of the string literal context and inject arbitrary SQL syntax. Without this line, an attacker can close the string and introduce UNION, OR, or other SQL keywords. The subsequent whitelist check (in_array() against $this->sort_data_types) is defence-in-depth: it catches any remaining injection attempts that might survive escaping, and more importantly, it prevents an attacker from exploiting esc_sql() in a context-dependent way—for example, by providing a cast type that is technically escaped but semantically dangerous in conjunction with other query fragments. The strict type comparison flag (true) in in_array() ensures that loose type juggling cannot trick the whitelist validator into accepting "0" when checking for integer keys.
Hardening Checklist
-
Use prepared statements for all user-influenced query fragments. Rather than escaping and concatenating, bind variables via
$wpdb->prepare()with%i(integer) or%s(string) placeholders, ensuring the database driver handles escaping context-aware and atomically. -
Maintain and enforce a whitelist for sort/order/cast keywords in every query builder. Store allowed
ORDER BYcolumns, sort directions, and type casts in a class constant or filtered array; validate user input against this list within_array( ..., true )before query construction. -
Apply
esc_sql()reflexively at the point of interpolation, not at the source. Even if a filter callback or configuration value should be safe, escape it at the moment it enters SQL context; this prevents trust-boundary violations when filters are extended by third parties. -
Audit all instances of
apply_filters()that output into SQL. Search codebase forapply_filters(followed by variable use in$wpdb->query()or string concatenation; these are high-risk patterns. Apply both escaping and validation to filter results before query use. -
Enable and test MySQL strict mode. Configure the database to reject unsafe queries; this catches injection attempts that slip past application-level escaping, and forces developers to use prepared statements. In WordPress, verify
STRICT_ALL_TABLESis enabled or use$wpdb->prepare()exclusively.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-1071