The Exploit
An authenticated user with Student-level access can escalate their privileges to Administrator by sending a single REST API request that modifies their own roles parameter. The plugin fails to validate that only admins should modify user roles during the update operation.
POST /wp-json/masteriyo/v1/users/123 HTTP/1.1
Host: target.wordpress.local
Content-Type: application/json
Authorization: Bearer <student_token>
{
"roles": ["administrator"]
}
The attacker receives a 200 OK response with their user object now assigned the administrator role. Within seconds, they can access wp-admin, modify site settings, create new accounts, install plugins, and exfiltrate sensitive data. No additional privileges, no social engineering, no credential theft — the API simply accepts the role change because the endpoint never checked current_user_can( 'manage_options' ) before applying it.
What the Patch Did
Before:
// User's role.
if ( isset( $request['roles'] ) ) {
$instructor->set_roles( $request['roles'] );
}
After:
/**
* Check if a given request has access to update an item.
*
* @since x.x.x
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_Error|boolean
*/
public function update_item_permissions_check( $request ) {
$result = parent::update_item_permissions_check( $request );
if ( is_wp_error( $result ) ) {
return $result;
}
if ( isset( $request['roles'] ) && ! current_user_can( 'manage_options' ) ) {
return new \WP_Error(
'masteriyo_rest_cannot_update',
__( 'Sorry, you are not allowed to change roles.', 'learning-management-system' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
// User's role.
if ( isset( $request['roles'] ) && current_user_can( 'manage_options' ) ) {
$instructor->set_roles( $request['roles'] );
}
The patch introduces a current_user_can( 'manage_options' ) capability check in two places: first in the update_item_permissions_check() method (which fires before the update is processed), and second as a guard clause in prepare_object_for_database() (which prevents the role assignment during object serialization). The permission check API is WordPress's built-in capability system; manage_options is the capability reserved for administrators and only administrators. The same fix was applied to UsersController.php in parallel.
Root Cause
CWE-862: Missing Authorization. The InstructorsController::prepare_object_for_database() method accepted a roles parameter from the REST request and passed it directly to set_roles() without validating that the requesting user possessed the manage_options capability. The dataflow enters via the REST API payload ($request['roles']), flows through update_item_permissions_check() (which was empty and performed no role-specific checks), and exits into the instructor object model via set_roles(). The trust boundary crossed was the REST API endpoint itself: the plugin treated all authenticated requests as equally privileged, even though WordPress has a multi-role permission model where Students and Instructors should never be able to promote themselves.
Why It Works
The load-bearing line is the capability check in update_item_permissions_check():
if ( isset( $request['roles'] ) && ! current_user_can( 'manage_options' ) ) {
return new \WP_Error( ... );
}
Without this, a Student's role-update request still reaches prepare_object_for_database(), which now also checks current_user_can( 'manage_options' ) before calling set_roles(). The second check is defence-in-depth: even if the first check is bypassed or removed, the second stops the role assignment. The engineer added both because REST permission checks run early (and should reject unauthorized requests immediately), while the object-preparation guard runs late (and should catch any role-modification attempts that leaked through). If either check were removed, the other remains; if both were removed, the vulnerability returns.
Hardening Checklist
- Use
update_item_permissions_check(): Always override this method in custom REST controllers to validate user capabilities before any updates are processed. Never rely on object-preparation methods alone. - Call
current_user_can()for sensitive mutations: When a REST request modifies roles, capabilities, or sensitive metadata, check the appropriate WordPress capability (e.g.,manage_options,manage_users,promote_users) in the authorization method, not in the callback. - Guard both the check and the sink: Add the capability check in two places—the permission-check method and the object-update logic—to ensure no code path can bypass authorization.
- List-and-reject sensitive fields: Maintain an explicit allowlist of fields that only admins can modify (roles,
admin_color, usermeta, etc.) and reject all role-modification requests unless the capability passes. - Use
rest_authorization_required_code(): Return HTTP 403 Forbidden (not 400 or 200) when denying authorization, so clients and logs clearly distinguish authorization failures from input validation failures.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-4484