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
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:NTechnical Details
<=4.16.11What Changed in the Fix
Changes introduced in v4.16.12
Source Code
WordPress.org SVN# 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 insrc/Membership/Controllers/CheckoutController.php) - Vulnerable Parameter:
change_plan_sub_id - Authentication: Authenticated (Subscriber+)
- Preconditions:
- At least one other user must have an active, high-value subscription.
- The attacker must know or enumerate the Subscription ID of the victim (typically a small integer).
- The site must have proration enabled or active for plan changes.
3. Code Flow Trace
- Entry Point: A POST request is sent to
admin-ajax.phpwithaction=ppress_process_checkout. - Controller:
ProfilePress\Core\Membership\Controllers\CheckoutController::process_checkout()is invoked. - Trait Logic: Inside the checkout processing logic, the code checks for the existence of
change_plan_sub_idin the$_POSTarray. - Subscription Loading: It calls
SubscriptionFactory::fromId($sub_id)to load the subscription object. - Missing Check: CRITICAL SINK: The code fails to validate that
$subscription->customer_idmatches thecustomer_idof the currently logged-in user. - Proration Calculation: The subscription is passed to
OrderService::get_time_based_pro_rated_upgrade_cost()or similar proration methods insrc/Membership/Services/OrderService.php. - Zero-Sum Checkout:
OrderService::is_free_checkout()is called. If the proration credit makes the totalNegativeOrZero, the plugin treats the transaction as free. - 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.
- Identify Checkout Page: Create a page containing a ProfilePress Checkout shortcode or use a direct plan checkout URL (e.g.,
?ppress_checkout_plan=1). - Script Localization: The nonce is localized in the
ppress_checkout_varsobject. - 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_nonceisppress_process_checkout.
- Create a test checkout page:
5. Test Data Setup
- Create Plans:
- Plan A (Victim): Expensive recurring plan (e.g., $500/month, ID 1).
- Plan B (Attacker): Lifetime plan (e.g., $1000, ID 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).
- Create a user
- Setup Attacker:
- Create a user
attacker_userwithSubscriberrole.
- Create a user
- 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_checkoutcheck inOrderService.phpwill returntruebecause the proration from the stolenchange_plan_sub_idoffsets the cost. - Outcome: The attacker is granted Plan B without a valid payment transaction.
8. Verification Steps
- Check Subscription Status: Run
wp ppress_subscription listand verify thatattacker_usernow has anactivesubscription for Plan ID 2. - Check Order Amount: Run
wp db query "SELECT total, status FROM wp_ppress_orders WHERE customer_id = [ATTACKER_ID]"and verify thetotalis0.00andstatusiscompleted. - Check Subscription Ownership: Verify that the subscription ID
5still belongs to thevictim_userbut 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.
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
@@ -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.