During a thorough analysis of WordPress’ internals, we discovered a subtle bug that allowed unauthenticated attackers to discern the email addresses of users who have published public posts on an affected website.
If successfully exploited, attackers could gather email addresses, putting user privacy at risk.
Upon identifying the vulnerability, we promptly alerted the WordPress team, who released version 6.3.2 to fix the issue. It is crucial for administrators to ensure their WordPress installations are fully updated to safeguard against this vulnerability.
WordPress’ official advisory can be found here.
The Vulnerability
| Fixed WordPress Versions | 6.3.2, 6.2.3, 6.1.4, 6.0.6, 5.9.8, 5.8.8, 5.7.10, 5.6.12, 5.5.13, 5.4.14, 5.3.16, 5.2.19, 5.1.17, 5.0.20, 4.9.24, 4.8.23, 4.7.27 |
| CVE-ID | CVE-2023-5561 |
| WPScan ID | 19380917-4c27-4095-abf1-eba6f913b441 |
| CVSSv3.1 | 5.3 |
/**
* Retrieves all users.
*
* @since 4.7.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
// Retrieve the list of registered collection query parameters.
$registered = $this->get_collection_params();
/*
* This array defines mappings between public API query parameters whose
* values are accepted as-passed, and their internal WP_Query parameter
* name equivalents (some are the same). Only values which are also
* present in $registered will be set.
*/
$parameter_mappings = array(
'exclude' => 'exclude',
'include' => 'include',
'order' => 'order',
'per_page' => 'number',
'search' => 'search',
'roles' => 'role__in',
'capabilities' => 'capability__in',
'slug' => 'nicename__in',
);
$prepared_args = array();
/*
* For each known parameter which is both registered and present in the request,
* set the parameter's value on the query $prepared_args.
*/
foreach ( $parameter_mappings as $api_param => $wp_param ) {
if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
$prepared_args[ $wp_param ] = $request[ $api_param ];
}
}
// (...)
if ( ! empty( $prepared_args['search'] ) ) {
$prepared_args['search'] = '*' . $prepared_args['search'] . '*';
}
/**
* Filters WP_User_Query arguments when querying users via the REST API.
*
* @link https://developer.wordpress.org/reference/classes/wp_user_query/
*
* @since 4.7.0
*
* @param array $prepared_args Array of arguments for WP_User_Query.
* @param WP_REST_Request $request The REST API request.
*/
$prepared_args = apply_filters( 'rest_user_query', $prepared_args, $request );
$query = new WP_User_Query( $prepared_args );
The /wp‑json/wp/v2/users REST API endpoint employed the `search` request parameter as an argument for the WP_User_Query constructor, enabling user‑search capabilities.
$search = '';
if ( isset( $qv['search'] ) ) {
$search = trim( $qv['search'] );
}
if ( $search ) {
$leading_wild = ( ltrim( $search, '*' ) != $search );
$trailing_wild = ( rtrim( $search, '*' ) != $search );
if ( $leading_wild && $trailing_wild ) {
$wild = 'both';
} elseif ( $leading_wild ) {
$wild = 'leading';
} elseif ( $trailing_wild ) {
$wild = 'trailing';
} else {
$wild = false;
}
if ( $wild ) {
$search = trim( $search, '*' );
}
$search_columns = array();
if ( $qv['search_columns'] ) {
$search_columns = array_intersect( $qv['search_columns'], array( 'ID', 'user_login', 'user_email', 'user_url', 'user_nicename', 'display_name' ) );
}
if ( ! $search_columns ) {
if ( false !== strpos( $search, '@' ) ) {
$search_columns = array( 'user_email' );
} elseif ( is_numeric( $search ) ) {
$search_columns = array( 'user_login', 'ID' );
} elseif ( preg_match( '|^https?://|', $search ) && ! ( is_multisite() && wp_is_large_network( 'users' ) ) ) {
$search_columns = array( 'user_url' );
} else {
$search_columns = array( 'user_login', 'user_url', 'user_email', 'user_nicename', 'display_name' );
}
}
However, during SQL query preparation, the WP_User_Query::prepare_query() defaulted to guessing the column to search based on the content of the search parameter. If explicit columns weren’t specified in the search_columns query variable, attackers could leverage this to deduce a user’s email address, character by character.
To address this, the WordPress Security team actively listed which columns could be searched based on the current user’s capabilities.
Exploitation
Oracle‑style attacks work by iteratively probing a vulnerable system until it gives a signal that a little piece of the information probed is correct, which when done enough times can be built upon to leak sensitive information. In the current context, this means an attacker would have to send multiple pings to vulnerable WordPress sites in order to guess an email address character by character.
A proof of concept will be made available on the WPScan entry for this issue on November 3, 2023.
Timeline
- 2023‑06‑12 – Details of the vulnerability sent to WordPress
- 2023‑10‑12 – Patch released by WordPress
Conclusion
Like with any WordPress security updates, auto-updates have been activated to automatically fix this issue on sites that support it. We recommend that you check which version of WordPress your site is using, and if it hasn’t been automatically updated, do so as soon as possible!
At WPScan, we work hard to make sure your websites are protected from these types of vulnerabilities. We recommend that you have a security plan for your site that includes malicious file scanning and backups. Jetpack Security is one great WordPress security option to ensure your site and visitors are safe.
Credits
Original research: Marc Montpas
Thanks to the rest of the WPScan team for feedback, help, and corrections.
Leave a reply to Marc Montpas Cancel reply