The Exploit
An unauthenticated attacker can inject arbitrary SQL into the field or field_where parameters of the plugin's database query methods by appending SQL operators and subqueries. No authentication or CSRF token is required.
GET /wp-admin/admin-ajax.php?action=td_get_appointment&field=id,1) UNION SELECT user_login,user_pass FROM wp_users WHERE (1=1 HTTP/1.1
Host: target-site.local
The response leaks database column names and schema information; a crafted payload using UNION SELECT against wp_users or wp_postmeta tables will extract password hashes, API keys, or customer PII stored in the database. The attacker observes valid rows from the injection subquery appearing in the JSON response where legitimate appointment data should be, confirming the query succeeded.
What the Patch Did
Before:
$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 = 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 a call to sanitize_key() on top of the existing esc_sql() call. sanitize_key() is a WordPress core function that strips all characters except alphanumerics and underscores, ensuring that only valid SQL identifiers (column names, table names) can be interpolated into the query string. This is the correct input filter for identifier sanitization in WordPress; esc_sql() alone is insufficient because it escapes quotes but does not restrict the character set.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
The attacker controls the field and field_where parameters via HTTP request parameters (likely GET or POST to an AJAX action handler). These values flow directly into the SQL query string via string interpolation:
"SELECT $field FROM {$this->get_table_name()} WHERE $field_where = %s"
Even though $field_value (the third parameter) is passed to wpdb->prepare() and safely parameterized, the identifier names themselves ($field, $field_where) are not parameterized — they are concatenated directly into the query string. The esc_sql() function escapes single and double quotes but does not restrict the character set; an attacker can still inject SQL operators, subqueries, and logical operators by crafting malicious field names such as id, (SELECT password FROM wp_users)--. The trust boundary is crossed the moment user input reaches these variables without sanitize_key().
Why It Works
The load-bearing line is $sanitized_field = sanitize_key( esc_sql( $field ) );. Removing only sanitize_key() while keeping esc_sql() would not prevent the injection because esc_sql() permits arbitrary SQL syntax — it only quotes string literals. An attacker can still inject operators, parentheses, and keywords. The engineer applied sanitize_key() before string interpolation to enforce a whitelist of safe characters (letters, digits, underscores); this is the only technique that prevents identifiers from being misused as injection points. The esc_sql() call is retained for defence-in-depth, but it is redundant after sanitize_key() strips all dangerous characters. The dual-layer approach ensures that even if sanitize_key() is accidentally removed in a future refactor, the code is not immediately broken — though still vulnerable.
Hardening Checklist
-
Use
sanitize_key()for all dynamically-constructed identifier names (column names, table names, field names) before string interpolation, even when combined withesc_sql(). Make it a code review rule: identifiers are never parameterizable in SQL, so they must be whitelisted. -
Audit all direct string interpolation in SQL queries across your plugin. Use a regex pattern to find
"SELECT.*\$var"or$wpdb->prepare( "... $var ..."and replace each with the correct sanitizer:sanitize_key()for identifiers,%s-parameterization for values. -
Replace
wpdb->prepare()calls that mix parameterization and interpolation with helpers that parameterize identifiers too, or use a query builder library that handles both safely. If your framework does not support identifier parameterization, document the constraint and enforce sanitization manually. -
Require
WP_List_Table::get_sortable_columns()validation if your AJAX handlers acceptorderororderbyparameters: whitelist them against a fixed set of approved column names using a switch statement rather than trusting user input as a field name. -
Add automated static analysis (PHPStan with WordPress rules, or SonarQube) to your CI pipeline to detect unparameterized variables in SQL query strings and flag them before merge.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-12166
- Simply Schedule Appointments plugin on WordPress.org