The Exploit
An authenticated WordPress user with subscriber-level access (or above) can escalate their own privileges to administrator by calling the vulnerable AJAX endpoint without administrator capabilities being checked.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<subscriber_session>
action=rm_update_users_role&rm_sec_nonce=<valid_nonce>&user_id=<attacker_user_id>&user_role=administrator
The attacker observes an HTTP 200 response with a success indicator in the AJAX handler output. Upon page reload or login refresh, the attacker's WordPress user account now carries the administrator role and can access /wp-admin/, create users, install plugins, and modify arbitrary website content.
What the Patch Did
Before
if(check_ajax_referer('rm_ajax_secure','rm_sec_nonce')) {
After
if(check_ajax_referer('rm_ajax_secure','rm_sec_nonce') && current_user_can('manage_options')) {
The patch adds a second security gate: current_user_can('manage_options'). The original code validated only the CSRF nonce token via check_ajax_referer() — a check that confirms the request originated from an authenticated session and carries a valid token, but does not verify that the requester has permission to perform the requested action. The fixed code chains the capability check with a boolean AND operator, ensuring that both conditions must be true: the nonce must be valid and the user must hold the manage_options capability (held only by administrators by default).
Root Cause
CWE-862: Missing Authorization — The update_users_role() function modifies user roles, a privileged operation that should be restricted to administrators. The function accepted requests from any authenticated user who could obtain a valid CSRF nonce. The nonce itself (rm_sec_nonce, echoed into the page during normal WordPress rendering) is available to all logged-in users, not just admins. The dataflow is: attacker crafts a POST request with parameters action=rm_update_users_role, user_id (the attacker's own user ID), user_role=administrator, and rm_sec_nonce (copied from any page the attacker visits while logged in). The request reaches the AJAX endpoint, the nonce is validated, and the role is updated — no capability check ever blocks the attacker.
Why It Works
The load-bearing line is current_user_can('manage_options'). Remove it and the vulnerability remains: the nonce check alone does not enforce privilege separation. check_ajax_referer() is not a capability check; it is a session check — it confirms you are who you say you are, not that you are allowed to do what you are asking. The engineer added the && to chain two independent security controls: session/origin validation (nonce) and role-based access control (capability). Only the capability check closes the privilege-escalation path.
Hardening Checklist
-
Audit all AJAX handlers — Search your codebase for
check_ajax_referer()calls not paired withcurrent_user_can()orcurrent_user_can_for_blog()checks. Usewp-clior grep to findadd_action('wp_ajax_')and verify each handler enforces a capability gate appropriate to its action. -
Define a capability matrix — For every administrative or user-modifying action (role change, user creation, settings update), document which WordPress capability should guard it. Use built-in caps like
manage_options,edit_users,edit_postsrather than custom strings; leverageuser_has_caphooks if custom roles require special rules. -
Test as a subscriber — Reproduce every AJAX-driven user-facing feature logged in as a subscriber. Attempt to modify other users, change settings, or invoke admin-only code paths. If any succeed without error, you have found an authorization bypass.
-
Use
wp_verify_nonce()with early return — Never nest nonce verification inside conditional logic. Use the patternif ( ! wp_verify_nonce(...) ) { wp_die(); }followed by your capability check. This prevents logical OR/AND bugs from short-circuiting the security gate. -
Employ the REST API's
permission_callback— If building new AJAX or REST endpoints, register them with an explicitpermission_callbackthat returns aWP_Errorif the user lacks required caps, rather than inlining capability checks into the handler function body.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-1991