Tutor LMS <= 3.9.7 - Missing Authorization to Authenticated (Subscriber+) Unauthorized Private Course Enrollment
Description
The Tutor LMS – eLearning and online course solution plugin for WordPress is vulnerable to unauthorized private course enrollment in all versions up to, and including, 3.9.7. This is due to missing post_status validation in the `enroll_now()` and `course_enrollment()` functions. Both enrollment endpoints verify the nonce, user authentication, and whether the course is purchasable, but fail to check if the course has a `private` post_status. This makes it possible for authenticated attackers with Subscriber-level access or above to enroll in private courses by sending a crafted POST request with the target course ID. The enrollment record is created in the database and the private course title and enrollment status are exposed in the subscriber's dashboard, though WordPress core access control prevents the subscriber from viewing the actual course content (returns 404). Enrollment in private courses should be restricted to users with the `read_private_posts` capability.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:NTechnical Details
What Changed in the Fix
Changes introduced in v3.9.8
Source Code
WordPress.org SVNThis analysis is based on the provided vulnerability description and the architectural patterns of the **Tutor LMS** plugin. Since the core PHP logic files were not provided in the snippet, the specific function locations and hooks are grounded in the CVE description and established Tutor LMS source…
Show full research plan
This analysis is based on the provided vulnerability description and the architectural patterns of the Tutor LMS plugin. Since the core PHP logic files were not provided in the snippet, the specific function locations and hooks are grounded in the CVE description and established Tutor LMS source patterns.
1. Vulnerability Summary
The vulnerability is a Missing Authorization (specifically, missing object status validation) within the enrollment logic of Tutor LMS. When a user attempts to enroll in a course, the functions enroll_now() and course_enrollment() (likely located in classes/Course.php or similar logic handlers) verify that the user is authenticated and that the request is not a Cross-Site Request Forgery (CSRF) via a nonce check.
However, they fail to verify the post_status of the target course_id. In WordPress, "Private" posts are intended to be hidden from everyone except those with the read_private_posts capability (typically Admins and Editors). By failing to check if a course is private, the plugin allows any authenticated user (Subscriber level) to create an enrollment record for a restricted course, effectively "joining" a class they should not even be able to see.
2. Attack Vector Analysis
- Endpoint: WordPress AJAX interface (
/wp-admin/admin-ajax.php) or theadmin-post.phphandler. - Action: The likely AJAX action is
tutor_enroll_nowor a direct POST to the enrollment handler. - Parameters:
course_id: (Integer) The ID of the private course.tutor_nonceor_wpnonce: (String) A valid nonce for the enrollment action.
- Authentication: Subscriber-level (or any authenticated user) is required.
- Preconditions: A course must exist with the
post_statusset toprivate.
3. Code Flow (Inferred)
- Entry Point: User triggers an enrollment request, usually via a POST request to
admin-ajax.phpwithaction=tutor_enroll_now. - Nonce Verification: The handler calls
check_ajax_referer('tutor_nonce', 'tutor_nonce')orwp_verify_nonce(). - Authentication Check: The handler verifies
is_user_logged_in(). - Purchasability Check: The code calls a helper like
tutor_utils()->is_course_purchasable($course_id). For many "Private" courses that are not connected to WooCommerce/EDD, this may returnfalse(meaning it's free/direct), allowing the flow to proceed. - The Flaw: The code proceeds to call
tutor_utils()->do_enroll($course_id)without checking ifget_post_status($course_id) === 'private'. - Sink: A record is inserted into the
{wpdb->prefix}tutor_enrolleddatabase table.
4. Nonce Acquisition Strategy
Tutor LMS localizes its security tokens into a global JavaScript object available on most frontend pages where Tutor LMS components are active (e.g., the Dashboard or Course Archive).
- Localization Key:
tutor_get_conf - Nonce Path:
tutor_get_conf.nonce - Strategy:
- Identify Trigger: The
[tutor_dashboard]shortcode is the most reliable way to ensure all Tutor LMS frontend scripts and nonces are loaded. - Setup Page: Create a temporary page containing this shortcode.
- Acquisition:
- Log in as a Subscriber.
- Navigate to the created page.
- Execute:
browser_eval("tutor_get_conf.nonce").
- Identify Trigger: The
5. Exploitation Strategy
This plan uses the http_request tool to simulate a Subscriber enrolling in a Private course.
Step 1: Discover/Identify Private Course ID
The attacker needs the ID of a private course. In a test environment, this is created during setup. In a real scenario, this might be found through ID enumeration or information leaks.
Step 2: Obtain Nonce
As the Subscriber, navigate to the Tutor Dashboard and extract the nonce.
Step 3: Execute Unauthorized Enrollment
Perform a POST request to the AJAX endpoint.
- URL:
http://localhost:8080/wp-admin/admin-ajax.php - Method:
POST - Headers:
Content-Type: application/x-www-form-urlencoded - Body:
action=tutor_enroll_now&course_id=[PRIVATE_COURSE_ID]&tutor_nonce=[EXTRACTED_NONCE]
6. Test Data Setup
To be performed via wp_cli:
- Create Private Course:
wp post create --post_type=courses --post_title="Top Secret Course" --post_status=private --post_author=1 # Note the resulting ID (e.g., 123) - Create Subscriber User:
wp user create attacker attacker@example.com --role=subscriber --user_pass=password - Create Nonce Source Page:
wp post create --post_type=page --post_title="Dashboard" --post_status=publish --post_content='[tutor_dashboard]'
7. Expected Results
- HTTP Response: The server should return a JSON success message (e.g.,
{"success":true,...}) or a redirect to the course page. - Dashboard Exposure: If the attacker visits their Subscriber dashboard, the "Top Secret Course" will now appear in their "Enrolled Courses" list.
- Database State: A new entry will exist in the
wp_tutor_enrolledtable linking theattackeruser ID to theprivatecourse ID.
8. Verification Steps
After the exploit attempt, verify the unauthorized enrollment via wp_cli:
# Check the tutor_enrolled table directly
wp db query "SELECT * FROM wp_tutor_enrolled WHERE course_id = [PRIVATE_COURSE_ID];"
# Verify if the user (ID 2) is now enrolled
wp db query "SELECT user_id, course_id FROM wp_tutor_enrolled WHERE user_id = 2 AND course_id = [PRIVATE_COURSE_ID];"
If a row exists for the Subscriber user and the Private course, the exploitation is confirmed.
9. Alternative Approaches
If the tutor_enroll_now AJAX action is restricted or behaves differently in the specific version:
- Direct POST: Try sending the request directly to a course URL with
tutor_enroll_now=trueand the required nonce and ID as query parameters or POST body. - Course Enrollment Action: Check for the
course_enrollmentaction (mentioned in the CVE description), which might be tied to a different hook likeadmin_post_tutor_course_enrollment. - Variable Guessing: If
tutor_noncefails, check the localized JS inassets/js/lazy-chunks/for other potential nonce identifiers used in the enrollment flow. (e.g., searching fornonceintutor-course-builder-basic.jsor similar frontend logic).
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.