The Exploit
Authenticated subscriber-level users can directly call Tutor LMS's course completion AJAX handler and mark any course complete.
curl 'https://TARGET/wp-admin/admin-ajax.php' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Cookie: wordpress_logged_in_XXXXXXXXXX' \
--data-raw 'action=mark_course_complete&course_id=123&user_id=1'
The response returns a success payload even when the attacker is not enrolled in course 123. The side effect is that the target course becomes marked complete for user 1; the attacker can abuse this to claim certificates, unlock progress-restricted content, or manipulate analytics.
What the Patch Did
Before:
$course_id = intval( $_POST['course_id'] );
$user_id = intval( $_POST['user_id'] );
// no enrollment check present before allowing course completion
$mark = tutor_utils()->mark_course_complete( $course_id, $user_id );
if ( $mark ) {
wp_send_json_success( __( 'Course marked as complete', 'tutor' ) );
}
After:
$course_id = intval( $_POST['course_id'] );
$user_id = intval( $_POST['user_id'] );
if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) {
die( esc_html__( 'User is not enrolled in course', 'tutor' ) );
}
$mark = tutor_utils()->mark_course_complete( $course_id, $user_id );
if ( $mark ) {
wp_send_json_success( __( 'Course marked as complete', 'tutor' ) );
}
The patch adds a server-side enrollment authorization check using the plugin helper tutor_utils()->is_enrolled(), preventing course completion unless the specified user is actually enrolled in that course.
Root Cause
This is a classic improper access control issue (CWE-284). The AJAX endpoint accepted attacker-controlled POST parameters course_id and user_id, then passed them straight into mark_course_complete() without verifying that the requester was enrolled in that course. The trust boundary crossed unchecked was the assumption that an authenticated user should only be able to complete their own enrolled content, but the code never validated enrollment before performing the state-changing action.
Why It Works
The load-bearing fix is the if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) check. Without that line, the request still reaches mark_course_complete() and the bug remains exploitable. The complementary die( esc_html__( ... ) ) is just the error-handling path; it turns the failed authorization into a safe response. The engineer added the helper check to enforce the missing authorization invariant, and wrapped it in esc_html__() to avoid leaking raw strings in the event of failure.
Hardening Checklist
- Use
current_user_can()or equivalent role checks for any action that changes course state, not just for page visibility. - Validate enrollment on every course progress endpoint with a server-side helper like
tutor_utils()->is_enrolled(), never trust a client-side flag. - Avoid accepting arbitrary
user_idvalues from POST for state-changing operations; derive the acting user fromget_current_user_id()when possible. - Return failures through WordPress AJAX-safe response helpers such as
wp_send_json_error()ordie( esc_html__( ... ) )instead of allowing the action to proceed. - Protect AJAX requests with nonces and validate them using
wp_verify_nonce()to reduce risk from CSRF combined with authorization gaps.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13935