The Exploit
An authenticated contributor who owns a draft Beaver Builder post can inject arbitrary SQL into the get_autosuggest_values AJAX endpoint via the fields[][value] parameter. The request below extracts the WordPress admin user list.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<contributor_session>
action=fl_builder_auto_suggest&nonce=<valid_nonce>&query_type=post&fields%5B%5D%5Bvalue%5D=1,2%20UNION%20SELECT%20ID,user_login%20FROM%20wp_users%20WHERE%201%3D1--
The attacker's SQL fragment is injected directly into the ORDER BY FIELD() clause of the query because the code fails to properly cast the $ids parameter to integers before string interpolation. The response returns a JSON array containing usernames extracted from the wp_users table, confirming data exfiltration. Subsequent requests using UNION-based or time-based blind SQL injection techniques allow the attacker to dump sensitive database columns without administrative privileges.
What the Patch Did
Before
if ( ! empty( $ids ) ) {
$order = implode( ',', array_filter( explode( ',', $ids ), 'intval' ) );
$list = explode( ',', $ids );
$how_many = count( $list );
$placeholders = array_fill( 0, $how_many, '%d' );
$format = implode( ', ', $placeholders );
$query = "SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ($format) ORDER BY FIELD(ID, $order)";
$posts = $wpdb->get_results( $wpdb->prepare( $query, $list ) );
After
$list = array_filter( array_map( 'absint', explode( ',', $ids ) ) );
if ( ! empty( $list ) ) {
$how_many = count( $list );
$placeholders = array_fill( 0, $how_many, '%d' );
$format = implode( ', ', $placeholders );
$query = "SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ($format) ORDER BY FIELD(ID, $format)";
$args = array_merge( $list, $list );
$posts = $wpdb->get_results( $wpdb->prepare( $query, $args ) );
The patch introduced three security controls working in concert. First, it replaced array_filter( explode( ',', $ids ), 'intval' ) with array_filter( array_map( 'absint', ... ) ) — array_map() with the absint callback actually converts values to integers, while intval as a filter callback only evaluated truthiness and left strings intact. Second, it eliminated direct string interpolation of the $order variable by replacing ORDER BY FIELD(ID, $order) with ORDER BY FIELD(ID, $format), using a parameterized placeholder instead. Third, it ensured both the IN clause and the ORDER BY FIELD clause receive bound integer parameters by passing array_merge( $list, $list ) to wpdb->prepare(), completing the defense-in-depth approach. The core security control is proper parameterized query construction via wpdb->prepare() with bound parameters, applied consistently across all user-influenced expressions in the SQL template.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command (SQL Injection)
The $ids parameter flows from the AJAX request parameter fields[][value] directly into the explode() call. The developer attempted input sanitization via array_filter( ... , 'intval' ), but this was ineffective because intval as a callback to array_filter() does not perform type coercion—it only tests truthiness and retains the original string values. The unsanitized $order variable (a comma-separated string derived from $ids) then crosses a critical trust boundary: it is directly interpolated into the SQL query string at line 169 (ORDER BY FIELD(ID, $order)), bypassing parameterized query protection. While the IN clause used wpdb->prepare() placeholders, the ORDER BY clause did not, creating an injectable SQL sink exposed to contributor-level authentication.
Why It Works
The load-bearing line is $list = array_filter( array_map( 'absint', explode( ',', $ids ) ) ). If this line were removed and developers reverted to the old array_filter( ... , 'intval' ) pattern, the bug would remain fully exploitable because intval still would not coerce the strings. The engineer added the subsequent lines ($format, array_merge( $list, $list ), and the %d placeholder in the ORDER BY clause) to create defense-in-depth: even if an attacker somehow bypassed the initial absint cast, the parameterized placeholder would prevent their payload from being interpreted as SQL. The use of absint instead of a looser sanitizer (like a regex strip) is critical—absint returns an integer type, not a filtered string, ensuring that by the time the value reaches the query template, the datatype itself is inviolable. The array_merge( $list, $list ) pattern, while appearing redundant, enforces the invariant that only values in the $list array (all integers) can flow into the query, eliminating any possibility of late-stage injection.
Hardening Checklist
-
Use
absint()orintval()with explicit type casting — never passintvalorintval()as a callback toarray_filter(). Instead, usearray_map( 'absint', ... )to guarantee return values are integers, not filtered strings. -
Apply
wpdb->prepare()to all dynamic SQL fragments — theORDER BY,LIMIT, and column names in expressions must be parameterized or hardcoded against a whitelist. Do not interpolate strings derived from user input, even after attempted sanitization. -
Audit callback usage in array functions — callbacks to
array_filter(),array_walk(), and similar functions do not invoke type coercion the way direct function calls do. Review existing code forintval,floatval, or other type-hint functions used as callbacks and replace witharray_map()equivalents. -
Test SQL injection payloads against ORDER BY and GROUP BY clauses specifically — these are frequently overlooked during security review because developers assume LIMIT or WHERE clauses are the only injection points. Add test cases like
1,2) UNION SELECT ...--to your integration test suite. -
Use static analysis tools like PHPStan with security rulesets — configure PHPStan or similar linters to flag direct string interpolation in any function call containing "query", "sql", or "prepare" in its name, catching typos like
$wpdb->prepare( $query, $list )where the second argument should be an array.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-95865