The Exploit
An unauthenticated attacker can trigger a password reset for any WordPress user by supplying only that user's username, then receive the reset link at an arbitrary email address they control.
POST /wp-json/complib/v1/form HTTP/1.1
Host: target.local
Content-Type: application/json
{
"form_id": "password-reset",
"username": "admin",
"email": "[email protected]"
}
The server responds with HTTP 200 and silently accepts the request. Moments later, a password reset email arrives in [email protected]'s inbox, addressed to the legitimate admin user but containing a reset link the attacker can use to set a new password and gain full account control.
What the Patch Did
Before:
if ( strlen( $username ) === 0 && isset( $form_data['email'] ) && strlen( $email ) > 0 ) {
$user = get_user_by( 'email', $email );
if ( $user ) {
$username = $user->get( 'user_login' );
} else {
$response = array(
'message' => 'User not found',
);
return new WP_REST_Response( $response, 404 );
}
}
if ( isset( $username ) && strlen( $username ) > 0 ) {
$user = get_user_by( 'login', $username );
if ( ! $user ) {
$response = array(
'message' => 'User not found',
);
return new WP_REST_Response( $response, 404 );
}
$key = get_password_reset_key( $user );
After:
if ( strlen( $username ) === 0 && isset( $form_data['email'] ) && strlen( $email ) > 0 ) {
$user = get_user_by( 'email', $email );
if ( ! $user ) {
return new WP_REST_Response( array( 'message' => 'User not found' ), 404 );
}
$username = $user->get( 'user_login' );
}
if ( empty( $username ) ) {
return new WP_REST_Response( array( 'message' => 'Invalid request' ), 400 );
}
if ( isset( $username ) && strlen( $username ) > 0 ) {
$user = get_user_by( 'login', $username );
if ( ! $user ) {
$response = array(
'message' => 'User not found',
);
return new WP_REST_Response( $response, 404 );
}
$user_email = $user->get( 'user_email' );
if($email !== $user_email) {
$response = array(
'message' => 'Invalid email address',
);
return new WP_REST_Response( $response, 404 );
}
$email = $user_email;
$key = get_password_reset_key( $user );
The patch introduces email verification as a mandatory gate before password reset key generation. After loading the user object by username, the code now compares the caller-supplied $email parameter against the user's legitimate registered email ($user->get( 'user_email' )). If they do not match, the request is rejected with a 404 response. This check—if($email !== $user_email)—enforces the principle that a password reset should only be sent to the email address the account owner registered, not to an attacker-controlled address.
Root Cause
CWE-287: Improper Authentication. The endpoint accepts a username parameter, retrieves the corresponding user object, and immediately generates a password reset token without verifying that the supplied email address belongs to that user. The attacker-controlled email parameter flows from the HTTP request body ($form_data['email']) directly into get_password_reset_key( $user ) without validation. The endpoint trusts that if a username exists, any email address associated with that request is legitimate. This violates the principle that password resets must be owned by the account holder: the reset link should only be sent to the registered email, not to an email the attacker provides. The logic-error compound—accepting an email from the request, optionally using it to look up a user, then bypassing email re-verification when a username is supplied directly—creates a two-path attack surface where the email gate is dropped on one path.
Why It Works
The load-bearing line is if($email !== $user_email). Without it, the function proceeds to generate and email a reset token to whatever address the attacker supplied. Removing this single line restores the vulnerability entirely. The engineers also added if ( empty( $username ) ) as a secondary validation gate—ensuring that even if an email lookup fails silently, a null username cannot reach the password reset key generation. This defense-in-depth approach catches two failure modes: an attacker who supplies a valid username directly (defeated by the email check), and an attacker who supplies a valid email that maps to a user, but the code path somehow loses the username (defeated by the empty username check). Neither gate alone is sufficient; together they seal the authentication boundary.
Hardening Checklist
-
Always verify ownership before sending sensitive links. After looking up a user by username, re-verify that the email address in the request matches the user's registered email using strict equality (
===or!==), not loose comparison. Do not allow callers to override or redirect password reset emails. -
Use
wp_verify_nonce()on all REST endpoints that modify account state or trigger actions with side effects. This Kirki endpoint should have required a nonce in the request and validated it server-side to prevent cross-site request forgery chains that could amplify this attack. -
Implement rate limiting and logging on password reset endpoints. Use a function like
wp_cache_get()/wp_cache_set()or a dedicated rate-limit library to allow only one reset request per user per hour, and log all reset attempts (username, supplied email, IP address) to detect abuse patterns. -
Separate the password reset request endpoint from the password reset confirmation endpoint. The request endpoint should only validate the username and send a link to the registered email, never to a caller-supplied address. A second endpoint, accessed via the emailed link, should then allow the user to set a new password.
-
Use WordPress's built-in password reset functions where possible. The
wp_lost_password()andwp_retrieve_password()functions already implement these checks. Only implement custom reset logic if the core functions do not meet your requirements, and audit the custom code against the same threat model.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-8206