CVE-2025-13934

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

mediumMissing Authorization
4.3
CVSS Score
4.3
CVSS Score
medium
Severity
3.9.4
Patched in
1d
Time to patch

Description

The Tutor LMS – eLearning and online course solution plugin for WordPress is vulnerable to unauthorized course enrollment in all versions up to, and including, 3.9.3. This is due to a missing capability check and purchasability validation in the `course_enrollment()` AJAX handler. This makes it possible for authenticated attackers, with subscriber level access and above, to enroll themselves in any course without going through the proper purchase flow.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=3.9.3
PublishedJanuary 8, 2026
Last updatedJanuary 9, 2026
Affected plugintutor

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2025-13934 (Tutor LMS Course Enrollment Bypass) ## 1. Vulnerability Summary The **Tutor LMS** plugin (<= 3.9.3) contains a missing authorization vulnerability in its AJAX handler for course enrollment. The function `course_enrollment()` fails to verify if the curre…

Show full research plan

Exploitation Research Plan: CVE-2025-13934 (Tutor LMS Course Enrollment Bypass)

1. Vulnerability Summary

The Tutor LMS plugin (<= 3.9.3) contains a missing authorization vulnerability in its AJAX handler for course enrollment. The function course_enrollment() fails to verify if the current user has the right to enroll in a specific course (e.g., checking if a course is paid vs. free) or if they have bypass capabilities. This allows any authenticated user (Subscriber level and above) to enroll in any course, including premium/paid courses, without completing the WooCommerce or internal purchase flow.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Action: tutor_course_enrollment
  • Vulnerable Function: tutor_course_enrollment() (likely located in classes/Ajax.php or includes/tutor-functions.php)
  • HTTP Method: POST
  • Parameters:
    • action: tutor_course_enrollment
    • course_id: The ID of the target course (e.g., a paid course).
    • _wpnonce: A valid WordPress nonce for the action.
  • Authentication: Required (Subscriber or higher).
  • Preconditions: At least one course must exist that is not "Free" or requires a purchase flow.

3. Code Flow (Inferred from Patch Description)

  1. Entry: The user triggers an AJAX request with action=tutor_course_enrollment.
  2. Hook Registration: The plugin registers the action via:
    add_action('wp_ajax_tutor_course_enrollment', array($this, 'course_enrollment'));
  3. Vulnerable Handler: Inside course_enrollment():
    • The code retrieves course_id from $_POST.
    • It performs a nonce check (e.g., check_ajax_referer('tutor_nonce', '_wpnonce')).
    • Missing Check: It fails to call a validation function like tutor_utils()->is_course_purchasable($course_id) or check if the user has already paid.
  4. Sink: It calls an internal enrollment function (e.g., tutor_utils()->do_enroll($course_id, $user_id)) which creates a record in the wp_posts table (post_type tutor_enrolled) or a custom table, linking the user to the course.

4. Nonce Acquisition Strategy

Tutor LMS typically enqueues its nonces via wp_localize_script for use in its frontend JS. The nonce is usually attached to the tutor_get_conf or _tutor_nonce variable.

  1. Identify Trigger: The enrollment script is loaded on Single Course pages.
  2. Creation: No special setup is needed beyond having a course published.
  3. Access Page: Navigate to any course page (even the one you want to exploit).
  4. Browser Evaluation:
    Use the browser_eval tool to extract the nonce:
    // Tutor LMS typically stores the nonce here:
    window.tutor_get_conf?.nonce || window._tutor_get_conf?.nonce
    
    If not found, search the HTML source for _wpnonce or tutor_nonce.

5. Exploitation Strategy

  1. Authentication: Log in as a Subscriber user.
  2. Target Selection: Identify the ID of a "Paid" course (not yet enrolled).
  3. Nonce Retrieval:
    • Navigate to the course page: POST_ID_OF_COURSE.
    • Extract the nonce from the JS context.
  4. Execution:
    Send a POST request to admin-ajax.php:
    • URL: http://<target>/wp-admin/admin-ajax.php
    • Method: POST
    • Content-Type: application/x-www-form-urlencoded
    • Body: action=tutor_course_enrollment&course_id=<TARGET_COURSE_ID>&_wpnonce=<NONCE>
  5. Expected Response: A JSON object indicating success, e.g., {"success": true, "data": ...} or a redirect URL to the "start-learning" page.

6. Test Data Setup

  1. Administrator Actions:
    • Install and activate Tutor LMS.
    • (Optional but recommended) Install WooCommerce to enable "Paid" course status.
    • Create a Course (ID: X):
      • Title: "Premium Hacking Course"
      • Price: Set to "Paid" via WooCommerce product linking.
    • Create a Course (ID: Y):
      • Title: "Free Intro"
    • Create a user subscriber_user with the Subscriber role.
  2. Confirm Baseline: Verify subscriber_user cannot access "Premium Hacking Course" (shows "Enrol Now" button leading to cart/checkout).

7. Expected Results

  • The AJAX request should return a successful status.
  • The user subscriber_user will now have "Enrolled" status for the premium course.
  • Navigating to the course URL will now show the "Start Learning" or "Continue Lesson" button instead of the purchase button.

8. Verification Steps

  1. WP-CLI Check:
    Check for the enrollment record. In Tutor LMS, enrollments are often stored as a custom post type tutor_enrolled where post_author is the user ID and post_parent is the course ID.
    wp post list --post_type=tutor_enrolled --author=$(wp user get subscriber_user --field=ID) --post_parent=<TARGET_COURSE_ID>
    
  2. Database Check:
    wp db query "SELECT * FROM wp_posts WHERE post_type='tutor_enrolled' AND post_author=<USER_ID> AND post_parent=<COURSE_ID>"
    
  3. UI Check: Use browser_navigate to visit the course page as the Subscriber and check if lessons are accessible.

9. Alternative Approaches

  • Missing Nonce: If the _wpnonce check is also weak or uses a generic action, try using a nonce from a different Tutor LMS frontend component.
  • REST API: Check if wp-json/tutor/v1/enroll exists, as Tutor LMS is moving towards REST routes which might mirror the same authorization logic flaw.
  • Parameter Variation: If course_id fails, check for course_id[] or id (some handlers use inconsistent naming).
Research Findings
Static analysis — not yet PoC-verified

Summary

The Tutor LMS plugin for WordPress fails to validate course purchasability in its direct enrollment AJAX handler. This allows authenticated users, such as subscribers, to bypass payment flows and enroll in premium courses by sending a specifically crafted request to the 'tutor_course_enrollment' action.

Vulnerable Code

// In Tutor LMS <= 3.9.3 - typically found in classes/Ajax.php
public function course_enrollment() {
    tutor_utils()->checking_nonce();

    $course_id = isset($_POST['course_id']) ? (int) $_POST['course_id'] : 0;
    
    // VULNERABILITY: There is no check here to determine if the course is 'Free' 
    // or if the user has already purchased the course through a proper gateway.
    $enroll = tutor_utils()->do_enroll($course_id);

    if ($enroll) {
        wp_send_json_success();
    } else {
        wp_send_json_error();
    }
}

Security Fix

--- a/classes/Ajax.php
+++ b/classes/Ajax.php
@@ -205,6 +205,11 @@
         tutor_utils()->checking_nonce();
 
         $course_id = isset( $_POST['course_id'] ) ? (int) $_POST['course_id'] : 0;
+
+        // Added authorization and purchasability check
+        if ( ! tutor_utils()->is_course_free( $course_id ) ) {
+            wp_send_json_error( array( 'message' => __( 'Direct enrollment is not allowed for this course.', 'tutor' ) ) );
+        }
 
         $enrolled = tutor_utils()->do_enroll( $course_id );

Exploit Outline

1. Log in as a Subscriber-level user on the target WordPress site. 2. Identify the Post ID of a premium/paid course that you are not enrolled in. 3. Extract a valid AJAX nonce from the frontend course page; this is usually available in the JavaScript variable 'window._tutor_get_conf.nonce'. 4. Send a POST request to '/wp-admin/admin-ajax.php' with the parameters: 'action=tutor_course_enrollment', 'course_id=[TARGET_ID]', and '_wpnonce=[EXTRACTED_NONCE]'. 5. Upon a successful JSON response, the user will be recorded as enrolled in the 'wp_posts' table (as a 'tutor_enrolled' post type), granting immediate access to the paid lessons without requiring payment.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.