The Exploit
An unauthenticated attacker can inject arbitrary SQL into time-based blind queries by manipulating the page_url parameter passed to the Remediation module's database query handler. The plugin must have the Remediation module active and connected to an Elementor account.
GET /wp-admin/admin-ajax.php?action=elementor_pro_get_global_remediations&page_url=http://target.local' OR SLEEP(5)-- HTTP/1.1
Host: target.local
User-Agent: Mozilla/5.0
When the request lands, the database JOIN clause concatenates the unescaped page_url value directly into the SQL statement. An attacker observing response times can confirm successful injection: a 5-second delay in the HTTP response signals that the injected SLEEP() function executed within the database query. By incrementally adjusting sleep durations and conditional logic, an attacker can extract arbitrary data (usernames, password hashes, email addresses) from the WordPress wp_users table or any other table accessible to the database user.
What the Patch Did
Before
$join = "LEFT JOIN $excluded_table ON $remediation_table.id = $excluded_table.remediation_id AND $excluded_table.page_url = '$url'";
After
$join = Remediation_Table::db()->prepare(
"LEFT JOIN $excluded_table ON $remediation_table.id = $excluded_table.remediation_id AND $excluded_table.page_url = %s",
$url
);
The patch replaced direct string concatenation with WordPress's wpdb::prepare() method, which implements parameterized query construction. The %s placeholder marks where the $url variable will be safely bound as a SQL string literal, not as executable SQL syntax. This is WordPress's standard defence against SQL injection in any context where user input reaches a database query.
Root Cause
CWE-89: SQL Injection. The $url variable originates from the page_url query parameter in an AJAX request (entry point: WordPress AJAX handler elementor_pro_get_global_remediations). This untrusted value is passed to the get_global_remediations() method in remediation-entry.php without sanitization or escaping for SQL context. Although esc_url_raw() is applied upstream for URL scheme validation, it does not strip or escape SQL metacharacters like single quotes or comment syntax (', --, /**/). The value is then directly concatenated into a SQL JOIN clause at line 215, crossing the trust boundary from user input to executable SQL without any parameterized query mechanism. A database parser interprets the concatenated string as SQL code, not as literal data, allowing an attacker to break out of the intended string literal and inject arbitrary SQL operators, functions, or sub-queries.
Why It Works
The load-bearing line is the call to wpdb::prepare() itself — it is the only mechanism that tells the database driver to treat the $url parameter as opaque data, not as SQL syntax. The %s placeholder acts as a type hint: the database driver will escape the value appropriately for a SQL string context (escaping single quotes, for example) and will never interpret anything after a quote as a new SQL operator. If you removed the prepare() call and kept the string concatenation, the bug remains exploitable; if you removed the %s placeholder but kept prepare(), the vulnerability also remains (the parameter is never bound). The vendor chose prepare() over alternative defences like esc_sql() because prepare() is the WordPress-native parameterized query API and provides defence-in-depth: it is harder for a future maintainer to accidentally circumvent, and it delegates escaping logic to the underlying database library (mysqli) rather than reimplementing it in PHP.
Hardening Checklist
-
Use
wpdb::prepare()with type placeholders (%s,%d,%f) for all user-supplied values in SQL queries. Never concatenate user input directly into SQL, even if you believe the input is validated or sanitized for a different context (e.g., URL validation does not prevent SQL injection). -
Audit AJAX handlers (
add_action('wp_ajax_...')) and REST endpoints for database queries. These are common entry points for untrusted input. Use static analysis or grep to find instances ofwpdb->query(),wpdb->get_results(), andwpdb->prepare()calls; confirm each one uses parameterized binding, not concatenation. -
Do not rely on URL sanitization (
esc_url_raw()) or HTML escaping (esc_html()) to prevent SQL injection. These functions are context-specific:esc_url_raw()removes schemes and protocols, not SQL syntax. Use the escaping function appropriate to the context where the data is used (SQL:prepare(), HTML:esc_html(), JavaScript:wp_json_encode(), attributes:esc_attr()). -
Enforce parameterized queries in code review and automated security scanning. Add a PHPCS rule or custom linter to flag string concatenation in SQL query contexts, or use a WordPress-aware static analysis tool like Semgrep with rules for
wpdbmisuse. -
Test time-based blind SQL injection payloads in staging. Write unit or integration tests that pass deliberately malicious
page_urlvalues (e.g.,' OR SLEEP(1)--,' UNION SELECT--) and confirm that the query execution time does not increase, and that no extraneous data is returned.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-2413