The Exploit
Unauthenticated attackers can abuse the plugin's public query endpoint by sending a specially crafted order payload to force a blind SQL injection.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: TARGET
Content-Type: application/x-www-form-urlencoded
action=ssa_get_appointments&order=id%2C+(SELECT+IF(SUBSTRING(USER(),1,1)='r',SLEEP(5),0))&append_where_sql=AND+1%3D1
The server executes the injected expression inside the ORDER BY clause; if the database user starts with r, the response is delayed by 5 seconds. A second probe can extract additional characters from USER() or other sensitive values.
The same flaw exists in append_where_sql, so an attacker can also inject conditions directly into the query's WHERE clause and perform blind extraction from the database.
What the Patch Did
Before:
$field_where = esc_sql( $field_where );
$field = esc_sql( $field );
return $wpdb->get_var( $wpdb->prepare( "SELECT $field FROM {$this->get_table_name()} WHERE $field_where = %s LIMIT 1;", $field_value ) );
After:
$sanitized_field_where = sanitize_key( esc_sql( $field_where ) );
$sanitized_field = sanitize_key( esc_sql( $field ) );
return $wpdb->get_var( $wpdb->prepare( "SELECT $sanitized_field FROM {$this->get_table_name()} WHERE $sanitized_field_where = %s LIMIT 1;", $field_value ) );
The patch added WordPress identifier sanitization via sanitize_key() on values that are interpolated into SQL identifiers and clause fragments, and it preserved the existing use of $wpdb->prepare() for query parameters.
Root Cause
This was a classic SQL injection bug (CWE-89). User-controlled values from the HTTP request — specifically order and append_where_sql — were passed into the plugin's query builder, escaped poorly with esc_sql(), and then concatenated into a SQL statement. That unchecked interpolation let attackers inject SQL logic into ORDER BY and WHERE fragments, crossing the trust boundary from raw request input into the database query engine.
Why It Works
The load-bearing fix is the addition of sanitize_key() on the dynamic SQL fragment identifiers. esc_sql() alone is intended for escaping string literals, not sanitizing database identifiers or raw SQL fragments. Without sanitize_key(), a malicious value like id, (SELECT IF(SUBSTRING(USER(),1,1)='r',SLEEP(5),0)) still reaches the SQL engine intact. The other fix lines are defense-in-depth: esc_sql() provides an additional escape layer, and $wpdb->prepare() keeps parameter values safe. But the key change is the strong validation of identifier-like inputs before they are interpolated into SQL.
Hardening Checklist
- Use
sanitize_key()for any user-controlled column name, sort field, or identifier before placing it in SQL. - Do not allow arbitrary SQL fragments from request parameters; if you must support ordering/filtering, map client values to a fixed allowlist of safe columns.
- Always build queries with
$wpdb->prepare()when user input is involved. - Avoid raw
ORDER BYorWHEREconcatenation; use explicit SQL fragments or whitelist checks instead. - For public query endpoints, minimize exposed query parameters and keep database access patterns as simple as possible.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-12166