The Exploit
An unauthenticated attacker can reset the email and password of any WordPress user account by sending a direct request to the customer profile update endpoint without authentication.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target-site.local
Content-Type: application/x-www-form-urlencoded
action=wtimetics_save_customer&id=1&first_name=Admin&last_name=User&[email protected]&password=NewPassword123&nonce=<any_value>
The attacker observes a successful HTTP 200 response with {"success":true} in the JSON body, and the administrator account's email is immediately changed to [email protected]. The password reset takes effect on the next login attempt, granting the attacker full control of the compromised account.
What the Patch Did
Before:
public function save() {
$args = [
'first_name' => $this->data['first_name'],
'last_name' => $this->data['last_name'],
'user_email' => $this->data['email'],
'user_pass' => $this->data['password'],
];
wp_update_user( array_merge( ['ID' => $this->id], $args ) );
}
After:
public function save() {
// Get the currently authenticated user
$current_user = wp_get_current_user();
// Ensure the user is logged in
if ( ! $current_user->ID ) {
$this->error = new \WP_Error( 'not_logged_in', 'You need to be logged in to update your profile.' );
return $this->error;
}
// Ensure the user can only update their own profile
if ( $this->id && $this->id !== $current_user->ID ) {
$this->error = new \WP_Error('unauthorized', 'You are not authorized to update this profile.' );
return $this->error;
}
$args = [
'first_name' => $this->data['first_name'],
'last_name' => $this->data['last_name'],
'user_email' => $this->data['email'],
'user_pass' => $this->data['password'],
];
wp_update_user( array_merge( ['ID' => $this->id], $args ) );
}
The patch introduced two sequential authentication and authorization checks using wp_get_current_user() and a user ID ownership comparison. The first check verifies that a user is logged in by testing the existence of $current_user->ID; the second check enforces that a user can only modify their own profile by comparing the supplied $this->id parameter against the authenticated user's ID. Both checks return a WP_Error object on failure, preventing any further execution of the wp_update_user() call.
Root Cause
CWE-639: Authorization Bypass Through User-Controlled Key. The $this->id parameter, which originates from the id GET or POST parameter in the AJAX request, is used directly to identify which user account to update without any validation that the requester has permission to modify that account. The unauthenticated or unprivileged user supplies an arbitrary user ID, and the code trusts that value immediately before passing it to wp_update_user(). No capability check, nonce verification, or session ownership test occurs between the attacker-controlled input and the sensitive WordPress user update function. This crosses the critical trust boundary between the request layer and the database modification layer without authorization enforcement.
Why It Works
The single load-bearing line is the authentication check if ( ! $current_user->ID ). Without this, an unauthenticated visitor can reach the authorization comparison below. If you removed only the second check if ( $this->id && $this->id !== $current_user->ID ), a logged-in attacker could still update any other user's profile by changing the id parameter—privilege escalation by lateral account hijacking becomes trivial. The engineer added both checks to implement defence-in-depth: the first gate denies all unauthenticated requests outright (stopping 99% of attacks), and the second gate stops an authenticated attacker from crossing user boundaries. Together they enforce the principle that you must be logged in and you can only update yourself.
Hardening Checklist
-
Use
wp_verify_nonce()on all AJAX actions: Callwp_verify_nonce( $_REQUEST['nonce'], 'action_name' )in the handler and reject any request without a valid nonce. This prevents Cross-Site Request Forgery, which can chain with IDOR to automate unauthorized updates. -
Whitelist the
$this->idparameter against the current user: Afterwp_get_current_user(), always compare user-supplied object identifiers against the authenticated user's ID or capability scope. Never assume the client sends valid IDs. -
Check capabilities before sensitive operations: Use
current_user_can()to verify that the logged-in user holds the appropriate WordPress capability (e.g.,edit_usersor equivalent) before allowing profile modifications of other users. -
Validate and sanitize all input parameters: Apply
absint()to numeric IDs andsanitize_email()to email fields before use, even after authorization checks pass. This prevents secondary injection vectors. -
Log authorization failures: Record failed authorization attempts to
error_log()or a dedicated audit table. This helps detect account takeover campaigns in progress.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-9263