The Exploit
Any unauthenticated user can register on an affected WordPress site running ACF Extended ≤0.9.2.1 with a user registration form that maps the role field to a custom field; the attacker submits administrator as the role value and gains full site access.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=acfe_form_submit&form_id=123&fields[user_email][email protected]&fields[user_login]=attacker&fields[user_pass]=Password123&fields[role]=administrator
The response contains a success message and a new user account with the administrator role appears in the WordPress user table. The attacker can immediately navigate to /wp-admin/ and modify site content, install plugins, or create additional backdoor accounts.
What the Patch Did
Before:
// check built-in validation
if(empty($action['validation'])){
return false;
}
// apply tags
$action = $this->setup_action($action, $form);
After:
// security measure
// check 'promote_users' capability for insert/update administrator role
if($action['type'] === 'insert_user' || $action['type'] === 'update_user'){
// get role as array
$role = acf_get_array($action['save']['role']);
// check capability
if((in_array('administrator', $role, true) || in_array('super_admin', $role, true)) && !current_user_can('promote_users')){
// filters
$validate = true;
$validate = apply_filters("acfe/form/validate_user_admin_role", $validate, $form, $action);
// should validate
if($validate){
return acfe_add_validation_error('', $errors['generic']);
}
}
}
The patch added a capability check using current_user_can('promote_users') that explicitly prevents unauthenticated and unprivileged users from assigning the administrator or super_admin roles during user creation or modification. This control is invoked before the generic validation logic runs, making it load-bearing in the privilege escalation defence.
Additionally, a companion validation method was added to field-user-roles.php that validates submitted role values against the declared field choices using array_diff(), preventing arbitrary role injection at the form field level.
Root Cause
CWE-269: Improper Access Control / CWE-20: Improper Input Validation
The insert_user and update_user actions in the form module accepted a role parameter from the request without verifying the caller's capability to assign that role. When a front-end registration form mapped the role field to a custom ACF field, an unauthenticated POST to /wp-admin/admin-ajax.php with action=acfe_form_submit would pass the attacker-supplied role=administrator value directly into the WordPress user creation function. The dataflow crosses the authentication boundary unchecked: the request parameter fields[role] travels through $action['save']['role'] into wp_insert_user() with no intermediate capability check. WordPress provides current_user_can('promote_users') to gate this operation; the original code never called it.
Why It Works
The single load-bearing line is:
if((in_array('administrator', $role, true) || in_array('super_admin', $role, true)) && !current_user_can('promote_users')){
If this condition were removed, an attacker could still inject any role value into the user object. The remaining lines (filter hook, validation error return) provide extensibility and consistent error handling — they are belt-and-suspenders defence-in-depth — but without the current_user_can() check itself, there is no gate at all. The array_diff() validation in the field-level method is a secondary control that raises the bar by also checking against declared field choices, but on a registration form where the developer intends to allow a user to self-select a role (a rare but real scenario), that field-level check might be disabled via allow_custom. The capability check in the action handler cannot be disabled without code change, making it the true anchor.
The engineer added the field-level validation because it follows the principle of defense-in-depth and catches arbitrary role injection at the form layer even if the action-level check is somehow bypassed. The filter hook ensures custom hardening logic can run; it is not required for security but improves enterprise compatibility.
Hardening Checklist
-
Always call
current_user_can()before assigning WordPress roles or capabilities. In user creation/update handlers, check the current user'spromote_userscapability against the submitted role(s) before passing them towp_insert_user()orwp_update_user(). Do not assume the form will be "private" or that only admins will submit it. -
Validate submitted choice values against declared field options using a whitelist. Implement a validation method like the one in the patch that calls
array_diff($submitted_values, $allowed_choices)and returns false if the diff is non-empty, unless the field explicitly allows custom values via a flag likeallow_custom. -
Use WordPress's built-in data access functions, not direct globals. Replace manual array access like
$_POST['role']withacf_get_array()or similar wrapper functions that are testable, loggable, and easier to intercept with hooks. -
Log privilege-escalation attempts. When the capability check fails, call
error_log()or a proper logging function with the user ID, requested role, and form ID. This surfaces attacks in security monitoring. -
Provide extensibility without disabling core security. Use
apply_filters()to let integrators add logic, but ensure the filter result is checked after the core control, not instead of it, as shown in the patch.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-14533