The Exploit
An authenticated student-level user can escalate to administrator by sending a single REST API PATCH request to their own user endpoint, supplying a roles array that includes administrator.
PATCH /wp-json/llms/v1/students/123 HTTP/1.1
Host: target.example.com
Authorization: Bearer <student_auth_token>
Content-Type: application/json
{
"roles": ["administrator"]
}
The attacker receives a 200 OK response with their user record modified to include the administrator role. Within seconds, they can visit /wp-admin/ and access the WordPress dashboard with full administrative privileges.
What the Patch Did
Before
if ( get_current_user_id() === $request['id'] ) {
return true;
}
if ( ! current_user_can( 'edit_students', $request['id'] ) ) {
return llms_rest_authorization_required_error( __( 'You are not allowed to edit this student.', 'lifterlms' ) );
}
return $this->check_roles_permissions( $request );
After
if ( is_wp_error( ( new WP_REST_Users_Controller() )->update_item_permissions_check( $request ) ) ) {
return llms_rest_authorization_required_error( __( 'You are not allowed to edit this user.', 'lifterlms' ) );
}
if ( ! empty( $request['roles'] ) ) {
return $this->check_roles_permissions( $request );
}
return true;
The patch replaced a permissive self-modification bypass with a two-stage permission check. First, it delegates to WordPress's core WP_REST_Users_Controller::update_item_permissions_check() method, which enforces the standard WordPress user-edit capability model. Second, it gates role modifications behind an explicit additional check: only if the request contains a roles parameter does the plugin call check_roles_permissions(). The old code had zero guards on role changes made during self-edits — it returned true immediately when get_current_user_id() === $request['id'], skipping all downstream role validation.
Root Cause
CWE-284: Improper Access Control.
The vulnerability lies in the update_item_permissions_check() method of both the students and instructors REST controllers. When a user updates their own account (detected via get_current_user_id() === $request['id']), the old code short-circuits the permission check and returns true without inspecting the request body. The roles parameter — passed unsanitized in the JSON request body — flows directly into the user update without validation. The trust boundary crossed is the one between the request handler and the capability system: the code assumes that "self-edit is always allowed" and never checks whether the current user has the capability to modify roles, only to update their own profile. WordPress separates these: editing your own user record is a lower bar than changing your own role. The plugin conflated them.
Why It Works
The load-bearing line is the call to ( new WP_REST_Users_Controller() )->update_item_permissions_check( $request ). Removing it would leave the bug fully exploitable, because the old code's get_current_user_id() === $request['id'] check would still fire and return true before any role validation runs. The ! empty( $request['roles'] ) gate on the role permission check is the secondary guard: it prevents the plugin from calling check_roles_permissions() on requests that don't attempt role modification, avoiding unnecessary overhead. But it is not load-bearing — a student could still bypass it if the WordPress core check were absent. The engineer added the secondary check for defence-in-depth: even if a caller somehow bypasses the WordPress check, the plugin will still validate role changes as a second opinion. The architectural insight is that role changes are a special capability in WordPress and LLMS — they must not be deduced from a blanket "you can edit your own profile" permission.
Hardening Checklist
-
Audit all
*_permissions_check()methods for shortcut logic: Search your codebase forif ( get_current_user_id() === $request['id'] ) return truepatterns. Replace them with calls to WordPress's standard REST user controller, or explicitly checkcurrent_user_can( 'edit_users' )at minimum. -
Separate profile edits from capability edits: Use
! empty( $request['roles'] ) || ! empty( $request['capabilities'] )conditionals to route role and capability modifications through a distinct, strict permission gate. Never treat them as incidental to a profile update. -
Delegate to WordPress core where possible: Before writing custom permission checks, consult
WP_REST_Users_Controller,WP_REST_Posts_Controller, and similar core controllers. Reuse their*_permissions_check()logic rather than reimplementing it; it receives security backports and is battle-tested. -
Test permissions with minimal roles: Write integration tests that attempt to call every REST endpoint with
subscriber,contributor, andauthorroles. Verify that privilege escalation vectors (role changes, capability grants) fail for all non-administrative accounts. -
Use
wp_die()or throw errors early on auth failures: Avoid patterns where a permission check returnstruebut a later operation fails silently. Return aWP_Errorobject early; it prevents the request body from being processed further.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-11923