The Exploit
Unauthenticated attacker sends a REST API request to the Popup builder plugin with a malicious campaign_id parameter containing SQL syntax.
GET /wp-json/popupbuilder/v1/analytics/device-stats?start_date=2025-01-01&end_date=2025-01-31&campaign_id=1%20OR%201=1 HTTP/1.1
Host: vulnerable-site.local
Accept: application/json
The server concatenates the unsanitized campaign_id directly into the SQL query string before calling wpdb->prepare(), allowing the injected SQL condition to execute. The attacker observes a response containing aggregated device statistics that should have been filtered to a single campaign — instead, data from all campaigns leaks because 1=1 makes the WHERE clause always true. By chaining UNION SELECT statements, the attacker can extract user tables, password hashes, or any other database content.
What the Patch Did
Before:
$campaign = $campaign_id ? " AND campaign_id = $campaign_id" : '';
return $wpdb->get_results(
$wpdb->prepare("SELECT SUM(device_desktop) as desktop,
SUM(device_tablet) as tablet,
SUM(device_mobile) as mobile
FROM %i WHERE date BETWEEN %s AND %s $campaign",
$table_name,
$start_date,
$end_date,
)
);
After:
if ( $campaign_id ) {
return $wpdb->get_results(
$wpdb->prepare(
"SELECT SUM(device_desktop) as desktop,
SUM(device_tablet) as tablet,
SUM(device_mobile) as mobile
FROM %i WHERE date BETWEEN %s AND %s AND campaign_id = %d",
$table_name,
$start_date,
$end_date,
$campaign_id
)
);
} else {
return $wpdb->get_results(
$wpdb->prepare(
"SELECT SUM(device_desktop) as desktop,
SUM(device_tablet) as tablet,
SUM(device_mobile) as mobile
FROM %i WHERE date BETWEEN %s AND %s",
$table_name,
$start_date,
$end_date
)
);
}
The patch moved $campaign_id from string interpolation outside the wpdb->prepare() call into a parameterized placeholder %d inside the query template. The %d placeholder tells WordPress's database abstraction layer to treat $campaign_id as an integer literal, escaping any SQL metacharacters. By splitting conditional logic into separate prepared statements, the fix ensures no user-controlled value is ever concatenated into the SQL string before preparation.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').
The campaign_id parameter enters the REST API endpoint unauthenticated. The application assigns it to the $campaign variable by direct string interpolation (" AND campaign_id = $campaign_id"), then passes this pre-built string to wpdb->prepare(). The prepared statement implementation expects the query template to be static; when dynamic SQL fragments are spliced in before preparation, the placeholder substitution logic cannot distinguish attacker input from legitimate SQL syntax. The trust boundary violation occurs because the attacker-controlled integer value is treated as executable code rather than data.
Why It Works
The load-bearing security control is the %d placeholder in the prepared statement template: "... AND campaign_id = %d". If that placeholder were absent and the code returned to direct concatenation, the injection would remain exploitable. The engineer added the conditional split (if ( $campaign_id ) { ... } else { ... }) to avoid a logical anomaly — if $campaign_id is omitted, the query must not include the AND campaign_id = %d clause at all, otherwise a NULL or zero value would corrupt the query logic. The separate prepared statement blocks ensure the template is never malformed. The %d type hint is the actual defence: it forces wpdb->prepare() to cast and escape the value as an integer, making 1 OR 1=1 render as the literal string "1" in the final query, not as a logical condition.
Hardening Checklist
- Use
wpdb->prepare()with type placeholders (%d,%s,%i) for every user-supplied variable, even integers that look harmless. Never concatenate variables into the query string before callingprepare(). - Audit all REST API endpoints for parameter sources using
$_GET,$_POST,$_REQUEST, orsanitize_*()callbacks; map each to its sink in a database query and verify it passes through a type-checked placeholder. - Enable WordPress security logging and monitor for SQL errors in production logs — injected SQL often fails on first attempt, generating database errors that reveal the vulnerability before data exfiltration succeeds.
- Run static analysis on custom SQL using plugins like WP Code Inspector or grep patterns for
wpdb->query()/wpdb->get_results()calls; flag any line where a variable name appears in the query string outside a placeholder. - Enforce capability checks on admin-facing analytics endpoints using
current_user_can( 'manage_options' )even if unauthenticated SQL injection is patched, to add a second control layer for defense in depth.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13192