The Exploit
An unauthenticated attacker can reset any non-administrator user's password without possession of a valid reset token.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
action=wpdm_password_reset&__wpdm_update_pass=<valid_nonce>&login=victim_user&__up_user=<encrypted_user_object>&password=attacker_password&cpassword=attacker_password
The attacker observes a 200 response with success: true, and the victim's password is immediately changed. The victim can no longer log in with their original credentials; the attacker now controls the compromised account.
What the Patch Did
Before:
<?php wp_nonce_field(NONCE_KEY,'__wpdm_update_pass' ); ?>
After:
<?php wp_nonce_field('wpdm_password_reset_' . $user->ID, '__wpdm_update_pass'); ?>
The patch replaced a global nonce action (NONCE_KEY) with a user-specific nonce action tied to the target user's ID. This prevents nonce reuse: a reset token generated for user_id=2 will no longer validate for user_id=3. Additionally, the patch added output escaping on the encrypted user object passed to the reset form:
Before:
<input type="hidden" name="__up_user" value="<?php echo Crypt::encrypt($user); ?>">
After:
<input type="hidden" name="key" value="<?php echo esc_attr(__::query_var('key')); ?>">
<input type="hidden" name="login" value="<?php echo esc_attr(__::query_var('login')); ?>">
The core security control added is user-scoped nonce validation via wp_nonce_field('wpdm_password_reset_' . $user->ID, ...) coupled with corresponding wp_verify_nonce() checks in the password reset handler. This ties each nonce cryptographically to a specific user, making it impossible to forge or reuse tokens across accounts.
Root Cause
CWE-352: Cross-Site Request Forgery (CSRF) combined with CWE-640: Weak Password Recovery Mechanism.
The password reset form accepted a global nonce action that did not vary by user. An attacker could:
- Visit the password reset endpoint with
login=victim_userand a valid (but globally reusable) nonce. - Craft a POST request to
wp-admin/admin-ajax.php?action=wpdm_password_resetwith an arbitraryloginparameter. - The nonce would validate because it was not bound to any specific user identity.
- The password update handler checked the nonce but never verified that the user making the request was the same user whose password was being reset.
The dataflow is: attacker-controlled login parameter → __up_user decryption → password update, all validated only by a nonce that was neither user-specific nor time-bound in a meaningful way. This crosses the authentication boundary without re-establishing the user's identity.
Why It Works
The load-bearing line is wp_nonce_field('wpdm_password_reset_' . $user->ID, ...). This single change forces the server to regenerate a cryptographically distinct nonce for each user's reset request. Without it, an attacker can reuse a leaked or guessed nonce across multiple users.
The secondary changes (escaping key and login, switching from encrypted user objects to plaintext reset tokens) are defence-in-depth. They reduce the attack surface by eliminating a poorly-sealed encryption channel and prevent DOM-based XSS if the encrypted payload is ever logged or reflected. But the nonce scoping is the critical fix: it ensures that even if an attacker obtains a valid nonce, it will only work for the user it was issued to. The engineer added the escaping to harden the form itself and reduce reliance on encryption as a security boundary—a sound practice when dealing with sensitive user data.
Hardening Checklist
- Use
wp_nonce_field()andwp_verify_nonce()with user-specific action strings for any form that modifies user state. Include the affected user's ID in the action slug (e.g.,'action_user_' . $user->ID). - Never rely on global constants as nonce actions. Always bind nonces to the entity being modified (user, post, comment). Treat nonce reuse as a critical bug.
- Validate user context in password reset handlers by checking
current_user_id()or by verifying that the reset token itself is bound to the target user via a database lookup (WordPress's core password reset does this viawp_get_password_reset_key()). - Escape all user-controlled values in HTML attributes using
esc_attr(), even if they are encrypted or hashed. Encryption is not escaping. - Avoid passing sensitive objects directly through forms. Use stateless tokens (HMAC-signed keys stored temporarily in the database or a JWT) instead of encrypted user objects, reducing the risk of tampering.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-15364