The Exploit
An unauthenticated attacker can log in as any user with a verified email by submitting an empty OTP value to the plugin's REST endpoint.
POST /wp-json/user-verification/v1/otpLogin HTTP/1.1
Host: target.wordpress.local
Content-Type: application/json
{
"email": "[email protected]",
"steps": 1,
"otp": "",
"_wpnonce": ""
}
The server responds with a session token or authentication cookie, granting full access to the admin account. The attacker observes a 200 OK response containing authentication credentials and a redirect instruction to the WordPress dashboard. No OTP was ever sent or validated; the empty string matched the uninitialized or absent stored OTP value due to missing input sanitization.
What the Patch Did
Before:
$saved_otp = get_user_meta($user_id, 'uv_otp', true);
if ($saved_otp != $otp_code) {
$UserVerificationStats->add_stats('wrong_email_otp_used');
$response['errors']['wrong_otp'] = __('Wrong OTP used.', 'user-verification');
return $response;
}
After:
$saved_otp = get_user_meta($user_id, 'uv_otp', true);
if(empty($otp_code)){
$UserVerificationStats->add_stats('wrong_email_otp_used');
$response['errors']['wrong_otp'] = __('Wrong OTP used.', 'user-verification');
return $response;
}
if(empty($saved_otp)){
$UserVerificationStats->add_stats('wrong_email_otp_used');
$response['errors']['wrong_otp'] = __('OTP missmatch.', 'user-verification');
return $response;
}
if ($saved_otp != $otp_code) {
$UserVerificationStats->add_stats('wrong_email_otp_used');
$response['errors']['wrong_otp'] = __('Wrong OTP used.', 'user-verification');
return $response;
}
The patch added explicit type-checking guards using empty() to reject authentication attempts when either the submitted OTP or the stored OTP value is absent. The plugin now validates the presence of both values before performing loose comparison, preventing the authentication logic from treating an empty string as a valid match against an unset or null stored OTP.
Root Cause
CWE-20: Improper Input Validation. The $otp_code parameter originates from the REST API request body ($request->get_param('otp')), an attacker-controlled source. It flows directly into the loose comparison operator (!=) without prior validation of type or presence. When a user submits an empty OTP string, the plugin retrieves the stored OTP (often empty or falsy if OTP generation failed or was never triggered), and the comparison '' != '' evaluates to false, allowing login. The trust boundary — between untrusted request data and authenticated action — is crossed unchecked.
Why It Works
The load-bearing line is if(empty($otp_code)). Removing it leaves the vulnerability intact: an empty string still compares equal to an empty stored value. The engineer added the second guard if(empty($saved_otp)) for defence-in-depth, catching the case where no OTP was ever generated on the backend. Together, these checks enforce the precondition that authentication requires both a client-submitted OTP and a server-stored OTP to exist before comparison proceeds. Without the first check, an attacker bypasses validation entirely; without the second, a race condition or backend bug that fails to store an OTP becomes exploitable.
Hardening Checklist
- Use strict comparison (
===) instead of loose (==) or (!=) when validating security-sensitive values like OTPs, tokens, or credentials. PHP's loose comparison can produce unexpected truthy/falsy equivalences (e.g.,0 == 'abc'is true). - Validate input presence and type before use: Call
empty(),isset(), oris_string()on all parameters before consuming them in conditionals. Combine withsanitize_text_field()orwp_kses_post()depending on context. - Implement nonce validation on all state-changing REST endpoints: The diff also shows the plugin should have added
wp_verify_nonce($_wpnonce, 'wp_rest')to reject cross-origin forged requests. Always callwp_verify_nonce()orcheck_admin_referer()before processing sensitive operations. - Log failed authentication attempts with full context: The patch's addition of
$UserVerificationStats->add_stats('wrong_email_otp_used')enables detection. Ensure logs include the email, timestamp, and failure reason so admins can identify brute-force campaigns. - Test the "happy path" and the "empty/null path" separately: Write unit tests that submit empty OTP, null OTP, and missing OTP parameters, then verify the endpoint rejects all three with the same error message (to avoid information leakage).
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-12374