The Exploit
An unauthenticated attacker can inject arbitrary SQL via the s parameter in the LeadsAjaxController AJAX endpoints. The parameter is passed directly into database queries after being sanitized with textfield() instead of SQL-specific escaping, allowing an attacker to break out of the query context and extract data.
GET /wp-admin/admin-ajax.php?action=depicter_leads_list&s=test' UNION SELECT 1,2,3,4,5,user_login,user_pass FROM wp_users WHERE '1'='1 HTTP/1.1
Host: target.wordpress.local
The attacker observes the injected SQL executes within the application's database query context. The response will contain database results from the UNION clause, leaking WordPress user credentials. Alternatively, time-based blind SQL injection (SLEEP(5)) confirms exploitability through response delay observation, bypassing any output filtering.
What the Patch Did
Before
's' => Sanitize::textfield($request->query('s', '')),
After
's' => Sanitize::sql($request->query('s', '')),
The patch replaces the generic textfield() sanitizer with sql() across three methods in LeadsAjaxController: index(), list(), and export(). The textfield() method removes HTML tags and performs basic text sanitization but does not escape SQL metacharacters like single quotes, double dashes, or semicolons. The sql() method applies proper SQL parameterization or character escaping that prevents the injected string from being interpreted as SQL syntax. This is a input filter change — the request parameter value is now escaped at the trust boundary between user input and the database query layer.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
The s query parameter enters the request unvalidated and reaches the LeadsAjaxController via $request->query('s', ''). The value is then passed to Sanitize::textfield(), which is insufficient because it was designed for HTML context (stripping tags) rather than SQL context. The sanitized value flows directly into SQL query construction in the index(), list(), and export() methods without parameterized queries or SQL-specific escaping. An attacker crosses the trust boundary — from untrusted user input to trusted database command — without encountering SQL-aware sanitization, allowing arbitrary SQL injection.
Why It Works
The load-bearing line is the replacement of textfield() with sql(). If the patch had only changed one of the three occurrences (line 13, 35, or 165), the vulnerability would remain exploitable through the remaining un-patched endpoints. The engineer correctly identified that the s parameter is used across all three query methods (index(), list(), export()) and applied the fix consistently. The specific choice of sql() over textfield() matters because SQL injection requires SQL-aware escaping: a string like admin' OR '1'='1 is harmless in HTML context but breaks SQL logic. The patch's consistency — fixing the parameter at the point of entry across all three methods rather than attempting to escape it at multiple query sites — follows the principle of defence-in-depth: sanitize once at the trust boundary, rather than hoping every downstream sink is defended.
Hardening Checklist
-
Use parameterized queries (prepared statements) instead of string concatenation. Replace all instances of
"WHERE name = '" . $user_input . "'"with prepared statements using placeholders (?or named parameters) and bind the user input separately. This eliminates the need to trust sanitizers. -
Apply context-aware sanitizers at input boundaries. Use
Sanitize::sql()(or equivalent database-specific escaping) for values destined for SQL; useesc_attr(),wp_kses_post(), oresc_html()for output contexts. Never reusetextfield()for SQL contexts. -
Audit all AJAX endpoints for direct database access. Use
grep -r "admin-ajax.php"andgrep -r "wp_db->prepare"to enumerate handlers; verify that every database query is either parameterized or applies SQL-specific escaping at the entry point. -
Introduce automated static analysis for SQL injection patterns. Integrate a linter that flags instances of
->query()or->get_results()preceded by string concatenation (e.g., no$wpdb->prepare()). Tools like PHPStan with security rules can catch this at CI time. -
Require code review sign-off for changes touching query construction. Establish a policy that any modification to SQL query building or parameter sanitization requires explicit review by a security-aware maintainer, not just a peer review.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-2011