The Exploit
The attacker does not require authentication; the vulnerability is exploitable from an unauthenticated state as long as the target site exposes a WP_Query instance through a publicly accessible endpoint that accepts the author__not_in parameter—common in custom REST API endpoints, theme shortcodes, or plugin AJAX handlers.
GET /wp-json/myplugin/v1/posts?author__not_in[]=1 UNION SELECT user_pass FROM wp_users-- HTTP/1.1
Host: target.wordpress.org
User-Agent: Mozilla/5.0
Accept: application/json
When the crafted request is processed, the site returns an HTTP 500 error rather than the expected JSON response. If error reporting is enabled, the response body may leak a MySQL error message containing the database type and version, confirming the SQL injection. Even without verbose error output, an attacker can detect the injection by observing a timing delay when substituting SLEEP(5) for the UNION component, indicating successful injection of SQL into the WHERE clause.
What the Patch Did
Before (vulnerable):
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) {
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
After (fixed):
if ( ! empty( $query_vars['author__not_in'] ) ) {
$author__not_in_id_list = wp_parse_id_list( $query_vars['author__not_in'] );
if ( count( $author__not_in_id_list ) > 0 ) {
sort( $author__not_in_id_list );
$where .= sprintf(
" AND {$wpdb->posts}.post_author NOT IN (%s) ",
implode( ',', $author__not_in_id_list )
);
/** Update the query var for stable cache key generation in {@see self::generate_cache_key()}. */
$query_vars['author__not_in'] = $author__not_in_id_list;
}
}
The patch replaces the nested absint() mapping approach (which had a bypass when the author__not_in value was provided as a single non-array string) with the WordPress API function wp_parse_id_list(). This function parses a comma-separated string or array of values, casting each element to an integer and filtering out any non-numeric entries. The key addition is the use of this specialized ID-parsing function, which provides robust sanitization of array elements before they reach the SQL query.
Root Cause
This is a classic SQL Injection vulnerability (CWE-89). The dataflow begins when an attacker supplies the author__not_in parameter in a WP_Query request—either via GET/POST variables, REST API parameters, or query string arguments in a shortcode. The parameter reaches wp-includes/class-wp-query.php without any prior sanitization. In the vulnerable code, the is_array() check creates a false sense of security: if the attacker passes author__not_in as a simple string (e.g., 1 UNION SELECT), the is_array() branch is skipped entirely. The code then falls through to implode( ',', (array) $query_vars['author__not_in'] ), which casts the string payload 1 UNION SELECT user_pass FROM wp_users to an array containing that single string element, and injects it directly into the SQL query without any sanitization. The trust boundary is crossed when attacker-controlled string data reaches the $where concatenation without undergoing scalar-type enforcement or integer casting.
Why It Works
The single most load-bearing line added by the patch is $author__not_in_id_list = wp_parse_id_list( $query_vars['author__not_in'] );. If this line were removed while keeping the rest of the patch (the sprintf formatting and the count() check), the vulnerability would remain exploitable—because the raw $query_vars['author__not_in'] would still be imploded into the SQL. The wp_parse_id_list() function is the only barrier that forces every input element to be an integer, eliminating any possibility of injecting SQL keywords or operators. The count( $author__not_in_id_list ) > 0 check is a defensive guard that prevents building an empty IN () clause if all parsed values were invalid (e.g., if the attacker passed only non-numeric strings completely). The sort() call is maintained for cache-key stability, not security. The engineer's use of sprintf for the overall formatting is a readability improvement but does not contribute to security—the real sanitization happens inside wp_parse_id_list.
Hardening Checklist
- Use
wp_parse_id_list()for any ID-based query parameter that entersWP_Queryor custom SQL, rather than rolling manualarray_map( 'absint', ... )logic—becauseabsintfails when a non-array string bypasses the array check. - Apply
sanitize_text_field()orabsint()to all raw HTTP input (e.g.,$_GET,$_POST,$request->get_param()) before passing it toWP_Queryparameters that are expected to be arrays, usingwp_unslash()to strip magic quotes first. - For custom SQL queries built from user input, always use
$wpdb->prepare()with%dplaceholder for integer values, never inject the concatenated result ofimplode(). - Audit all custom REST API endpoints, shortcode callbacks, and AJAX handlers that accept
author__not_in,post__in,post__not_in,author__in, or any similar array-type query variable—these are the most common entry points for bypassingWP_Query's internal sanitization. - Enable
WP_DEBUG_DISPLAYonly in development environments andWP_DEBUG_LOGfor production to prevent verbose SQL errors from being displayed to attackers during blind injection attempts.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-60137
- https://www.wordfence.com/threat-intel/vulnerabilities/id/cve-2026-60137