The Exploit
An unauthenticated attacker can inject arbitrary SQL into coupon lookups by appending SQL metacharacters to the coupon_code parameter.
GET /wp-admin/admin-ajax.php?action=tutor_get_coupon_details&coupon_code=VALID' UNION SELECT user_login, user_pass, 3, 4, 5 FROM wp_users WHERE '1'='1 HTTP/1.1
Host: target.local
The attacker observes a JSON response containing password hashes from the wp_users table. If no error suppression is in place, they see UNION query results merged into the coupon details response; if errors are suppressed, they can use time-based blind injection (SLEEP()) to infer data byte-by-byte.
What the Patch Did
Before
'coupon_code' => esc_sql( $coupon_code ),
After
'coupon_code' => $coupon_code,
Wait — this looks backwards. The patch removes esc_sql(). Let's look at the real fix in QueryHelper.php:
Before
default: // =, !=, <, >, <=, >=, LIKE, NOT LIKE, <>
$val = is_numeric( $val ) ? $val : "'" . $val . "'";
$clause = array( $field, $operator, $val );
break;
After
else {
$value = self::prepare_value( $val );
}
return "{$field} {$upper_operator} {$value}";
The patch introduces a new prepare_value() method that wraps all scalar values in wpdb->prepare('%s', $value) or wpdb->prepare('%d', $value) depending on type. This shifts the escaping responsibility from esc_sql() (string-only, insufficient) to parameterized queries using WordPress's $wpdb->prepare(). The removal of esc_sql() from CouponModel.php is intentional — it signals that the value will be processed through the query builder, not directly concatenated.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command.
The coupon_code parameter arrives via HTTP request (typically $_GET['coupon_code'] or REST API input) and flows into CouponModel::get_coupon(). Before the patch, this method passed the coupon code to QueryHelper::build_where_clause(), which constructed SQL fragments by string concatenation. For operators other than IN and NOT IN, the code checked is_numeric() and then wrapped the value in single quotes without escaping, leaving SQL metacharacters like ', --, /*, and UNION unescaped. A malicious value like VALID' UNION SELECT ... would break out of the intended query and execute arbitrary SQL.
Why It Works
The load-bearing line is $value = self::prepare_value( $val ). This call routes the value through wpdb->prepare(), which uses parameterized queries (prepared statements in MySQL parlance) to separate code from data. The old code tried to "fix" the problem with is_numeric() and manual quoting — these additions are security theater. is_numeric('1e5') returns true, but '1e5' as a number can trigger scientific notation in some contexts. There is no safe way to concatenate user input into SQL by hand. The patch removes that impossible task entirely, delegating it to the database driver.
The additional cases for BETWEEN and NULL operators are load-bearing too: they ensure that all code paths through build_where_clause() use prepare_value(), closing loopholes where an attacker could choose an unusual operator to bypass escaping.
Hardening Checklist
-
Use
wpdb->prepare()for all variable interpolation — never concatenate user input into SQL, even if you think you've "escaped" it withesc_sql(). Pass a format string ('%s','%d','%i') and let the driver parameterize. -
Audit all QueryBuilder-like classes for string concatenation — grep your codebase for
return "{$field}"orarray( $field, ... )patterns that construct SQL. Each must pass through$wpdb->prepare()before database execution. -
Validate operator whitelist in switch statements — if your code accepts an operator parameter, use a strict whitelist (
IN,=,LIKE, etc.) and reject unrecognized values. The patch implicitly does this by handlingBETWEENin its own case; unhandled operators fall through to theelseblock. -
Test with time-based blind SQL injection payloads — include
' OR SLEEP(5)--and' AND SLEEP(5)--in your unit test suite for any parameter that flows to the database. If response time increases, your escaping is bypassed. -
Never rely on
is_numeric()for SQL type detection — it is a type check, not a sanitizer. Use explicit integer casting ((int)$val) orintval()only if you know the field is numeric, then validate the result is within expected range.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13673