CVE-2026-3445

Paid Membership Plugin, Ecommerce, User Registration Form, Login Form, User Profile & Restrict Content – ProfilePress <= 4.16.11 - Missing Authorization to Authenticated (Subscriber+) Membership Payment Bypass

highMissing Authorization
7.1
CVSS Score
7.1
CVSS Score
high
Severity
4.16.12
Patched in
1d
Time to patch

Description

The Paid Membership Plugin, Ecommerce, User Registration Form, Login Form, User Profile & Restrict Content – ProfilePress plugin for WordPress is vulnerable to unauthorized membership payment bypass in all versions up to, and including, 4.16.11. This is due to a missing ownership verification on the `change_plan_sub_id` parameter in the `process_checkout()` function. This makes it possible for authenticated attackers, with subscriber level access and above, to reference another user's active subscription during checkout to manipulate proration calculations, allowing them to obtain paid lifetime membership plans without payment via the `ppress_process_checkout` AJAX action.

CVSS Vector Breakdown

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

Technical Details

Affected versions<=4.16.11
PublishedApril 3, 2026
Last updatedApril 4, 2026
Affected pluginwp-user-avatar

What Changed in the Fix

Changes introduced in v4.16.12

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Research Plan: ProfilePress Membership Payment Bypass (CVE-2026-3445) ## 1. Vulnerability Summary The **ProfilePress** plugin (versions <= 4.16.11) contains a missing authorization vulnerability in its checkout processing logic. Specifically, the `process_checkout()` function (located in `Checkou…

Show full research plan

Research Plan: ProfilePress Membership Payment Bypass (CVE-2026-3445)

1. Vulnerability Summary

The ProfilePress plugin (versions <= 4.16.11) contains a missing authorization vulnerability in its checkout processing logic. Specifically, the process_checkout() function (located in CheckoutTrait and used by CheckoutController) accepts a change_plan_sub_id parameter to handle subscription upgrades or downgrades.

The plugin fails to verify that the subscription ID provided in change_plan_sub_id actually belongs to the user performing the checkout. An authenticated attacker (Subscriber level) can reference a high-value active subscription belonging to another user. The plugin's proration engine calculates the remaining value of the "old" (victim's) subscription and applies it as a credit toward the attacker's "new" plan. If the victim's subscription credit exceeds the cost of the attacker's target plan, the total becomes zero, allowing the attacker to obtain paid memberships (including lifetime plans) for free via the ppress_process_checkout AJAX action.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Action: ppress_process_checkout (Registered in src/Membership/Controllers/CheckoutController.php)
  • Vulnerable Parameter: change_plan_sub_id
  • Authentication: Authenticated (Subscriber+)
  • Preconditions:
    1. At least one other user must have an active, high-value subscription.
    2. The attacker must know or enumerate the Subscription ID of the victim (typically a small integer).
    3. The site must have proration enabled or active for plan changes.

3. Code Flow Trace

  1. Entry Point: A POST request is sent to admin-ajax.php with action=ppress_process_checkout.
  2. Controller: ProfilePress\Core\Membership\Controllers\CheckoutController::process_checkout() is invoked.
  3. Trait Logic: Inside the checkout processing logic, the code checks for the existence of change_plan_sub_id in the $_POST array.
  4. Subscription Loading: It calls SubscriptionFactory::fromId($sub_id) to load the subscription object.
  5. Missing Check: CRITICAL SINK: The code fails to validate that $subscription->customer_id matches the customer_id of the currently logged-in user.
  6. Proration Calculation: The subscription is passed to OrderService::get_time_based_pro_rated_upgrade_cost() or similar proration methods in src/Membership/Services/OrderService.php.
  7. Zero-Sum Checkout: OrderService::is_free_checkout() is called. If the proration credit makes the total NegativeOrZero, the plugin treats the transaction as free.
  8. Completion: The order is marked as completed without ever redirecting to a payment gateway.

4. Nonce Acquisition Strategy

The ppress_process_checkout action requires a nonce. In ProfilePress, this is typically localized for the checkout page.

  1. Identify Checkout Page: Create a page containing a ProfilePress Checkout shortcode or use a direct plan checkout URL (e.g., ?ppress_checkout_plan=1).
  2. Script Localization: The nonce is localized in the ppress_checkout_vars object.
  3. Extraction:
    • Create a test checkout page: wp post create --post_type=page --post_status=publish --post_content='[profilepress-checkout id="1"]'
    • Navigate to the page using browser_navigate.
    • Extract the nonce: browser_eval("window.ppress_checkout_vars?.checkout_nonce").
    • The action string used in wp_create_nonce is ppress_process_checkout.

5. Test Data Setup

  1. Create Plans:
    • Plan A (Victim): Expensive recurring plan (e.g., $500/month, ID 1).
    • Plan B (Attacker): Lifetime plan (e.g., $1000, ID 2).
  2. Setup Victim:
    • Create a user victim_user.
    • Manually create or simulate an active subscription for Plan A for this user.
    • Note the Subscription ID (let's assume it's 5).
  3. Setup Attacker:
    • Create a user attacker_user with Subscriber role.
  4. Configure Plugin: Ensure the "Bank Transfer" or "Stripe" gateway is active so the checkout form can render.

6. Exploitation Strategy

Step 1: Login as Attacker

Use the http_request tool to authenticate as attacker_user.

Step 2: Extract Nonce

Navigate to a checkout page for Plan B and extract ppress_checkout_vars.checkout_nonce.

Step 3: Execute Bypass

Send a POST request to admin-ajax.php referencing the Victim's subscription ID.

HTTP Request:

  • URL: http://localhost:8080/wp-admin/admin-ajax.php
  • Method: POST
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body:
action=ppress_process_checkout
&ppress_checkout_nonce=[EXTRACTED_NONCE]
&plan_id=2
&change_plan_sub_id=5
&ppmb_billing_first_name=Attacker
&ppmb_billing_last_name=User
&ppmb_billing_email=attacker@example.com
&ppmb_payment_method=bank_transfer
&ppmb_billing_country=US
&ppmb_billing_address=123+Street
&ppmb_billing_city=New+York
&ppmb_billing_state=NY
&ppmb_billing_postcode=10001

7. Expected Results

  • Response: The server should return a JSON success response, often including a redirect URL to a "Success" page.
  • Payment Bypass: The is_free_checkout check in OrderService.php will return true because the proration from the stolen change_plan_sub_id offsets the cost.
  • Outcome: The attacker is granted Plan B without a valid payment transaction.

8. Verification Steps

  1. Check Subscription Status: Run wp ppress_subscription list and verify that attacker_user now has an active subscription for Plan ID 2.
  2. Check Order Amount: Run wp db query "SELECT total, status FROM wp_ppress_orders WHERE customer_id = [ATTACKER_ID]" and verify the total is 0.00 and status is completed.
  3. Check Subscription Ownership: Verify that the subscription ID 5 still belongs to the victim_user but its "value" was used to credit the attacker's order.

9. Alternative Approaches

If the change_plan_sub_id is not accepted via the default checkout form, try triggering the "Plan Switch" mode by appending ?ppress_switch_plan=[TARGET_PLAN_ID]&sub_id=[VICTIM_SUB_ID] to the checkout URL. This often pre-populates the internal session state used by process_checkout().

If is_free_checkout fails, the attacker might still achieve a Partial Bypass (e.g., getting a $1000 plan for $1 by referencing a $999 subscription), which still fulfills the "Membership Payment Bypass" criteria.

Research Findings
Static analysis — not yet PoC-verified

Summary

The ProfilePress plugin is vulnerable to an unauthorized membership payment bypass due to missing ownership verification on the 'change_plan_sub_id' parameter during checkout. Authenticated attackers can reference a high-value subscription belonging to another user to apply its proration credit to their own order, potentially reducing the cost of premium or lifetime plans to zero.

Vulnerable Code

// src/Membership/Controllers/CheckoutController.php - missing verification before order creation
// around line 303 in vulnerable version

            if (is_wp_error($customer_id)) {
                throw new \Exception(json_encode($customer_id->get_error_messages()));
            }

            $order_id = $this->create_order($customer_id, $cart_vars);

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/wp-user-avatar/4.16.11/src/Membership/Controllers/CheckoutController.php	2026-03-03 12:42:30.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-user-avatar/4.16.12/src/Membership/Controllers/CheckoutController.php	2026-03-04 12:06:58.000000000 +0000
@@ -300,6 +300,17 @@
                 throw new \Exception(json_encode($customer_id->get_error_messages()));
             }
 
+            $changePlanSub = SubscriptionFactory::fromId($change_plan_sub_id);
+
+            if (
+                $changePlanSub->exists() &&
+                ! empty($customer_id) &&
+                $customer_id !== $changePlanSub->get_customer_id()) {
+                throw new \Exception(
+                    esc_html__('You are not allowed to switch from this plan.', 'wp-user-avatar')
+                );
+            }
+
             $order_id = $this->create_order($customer_id, $cart_vars);
 
             if (is_wp_error($order_id)) {
@@ -331,19 +342,17 @@
 
             } else {
 
-                $sub = SubscriptionFactory::fromId($change_plan_sub_id);
-
-                if ($sub->exists() && $sub->get_customer_id() == $customer_id) {
+                if ($changePlanSub->exists() && $changePlanSub->get_customer_id() == $customer_id) {
 
                     // do not send subscription cancelled email
                     remove_action('ppress_subscription_cancelled', [SubscriptionCancelledNotification::init(), 'dispatch_email'], 10);
                     remove_action('ppress_subscription_expired', [SubscriptionExpiredNotification::init(), 'dispatch_email'], 10);
 
-                    $sub->cancel(true);
-                    $sub->expire();
+                    $changePlanSub->cancel(true);
+                    $changePlanSub->expire();
 
-                    SubscriptionFactory::fromId($subscription_id)->update_meta('_upgraded_from_sub_id', $sub->get_id());
-                    $sub->update_meta('_upgraded_to_sub_id', $subscription_id);
+                    SubscriptionFactory::fromId($subscription_id)->update_meta('_upgraded_from_sub_id', $changePlanSub->get_id());
+                    $changePlanSub->update_meta('_upgraded_to_sub_id', $subscription_id);
                 }
 
                 /** @var CheckoutResponse $process_payment */

Exploit Outline

To exploit this vulnerability, an attacker needs a standard subscriber account and the ID of an active, expensive subscription belonging to another user. 1. Log in as a Subscriber. 2. Locate a checkout page for a premium plan (e.g., a lifetime plan). 3. Capture the localized checkout nonce from the page source (window.ppress_checkout_vars.checkout_nonce). 4. Send an AJAX POST request to '/wp-admin/admin-ajax.php' with 'action=ppress_process_checkout'. 5. In the payload, include the target premium plan ID and set 'change_plan_sub_id' to the victim's subscription ID. 6. The plugin's proration engine will calculate the remaining value of the victim's subscription and apply it as a credit to the attacker's order. 7. If the victim's credit equals or exceeds the cost of the attacker's new plan, 'OrderService::is_free_checkout()' will evaluate to true, and the membership will be granted without requiring payment.

Check if your site is affected.

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