CVE-2026-15014: Unbound OTP Verification State in SMS Alert
An unauthenticated account takeover caused by a verification boolean that survived an identity change.

While auditing authentication mechanisms in WordPress plugins, I spent some time looking into SMS Alert, a plugin that provides SMS and OTP functionality for WooCommerce.
I was mainly interested in how the plugin carried authentication state from OTP verification to the point where WordPress finally issued an authentication cookie. OTP flows often span several requests, and small mistakes in how that state is preserved can have much larger consequences later in the flow.
This audit resulted in CVE-2026-15014, a CVSS 9.8 unauthenticated account takeover vulnerability. The OTP itself was not broken. The identity attached to a successful OTP result was missing.
1. Understanding the authentication flow
My initial goal was to map the journey from entering a phone number to receiving a valid WordPress authentication cookie.
I set up WooCommerce and SMS Alert locally, then proxied the traffic through Burp Suite. The visible flow was conventional: initiate an OTP for a phone number, submit the code, then authenticate the matching user.
This kind of flow is awkward for conventional scanners because the interesting behavior appears only after several stateful requests and a valid OTP exchange. To learn what the server trusted between verification and authentication, I moved to the source and traced how successful verification was recorded.
Phone AOTP issuedpending(A)Phone A + valid codeCode acceptedverified = truebilling_phone = AResolve user Acookie(A)2. Following sa_mobile_verified
I traced the functions responsible for OTP validation, looking specifically for $_SESSION assignments. In PHP applications, session variables commonly carry authentication state across requests.
01if ($submitted_otp === $stored_otp) {02 $_SESSION['sa_mobile_verified'] = true;03}That assignment immediately stood out. The session confirms that an OTP was successfully verified, but it does not record which phone number was verified.
Later, processRegistration() reads billing_phone from the current request, resolves the corresponding account, and reaches wp_set_auth_cookie(). If $_SESSION['sa_mobile_verified'] is true, that separately supplied phone number is trusted.
The proof and the identity therefore come from different places. The proof survives in the session. The identity remains mutable in the next request. Nothing binds them together.
- sa_mobile_verified
- true
- verified_phone
- not stored
- billing_phone
- Phone V
- resolved_user
- administrator V
3. Testing the state mismatch
The test case followed directly from the state model. I verified a phone number I controlled, preserved the resulting session, and changed the phone number before the account lookup.
First, I requested an OTP for Phone A and submitted the valid code normally. The server accepted it and set sa_mobile_verified = true for my session.
I then intercepted the following request and replaced Phone A in billing_phone with Phone V, the number associated with an administrator account in my test environment.
The verification state remained valid. The plugin resolved Phone V, selected that user, and returned a valid WordPress authentication cookie for the administrator account.
No OTP for Phone V was required. I did not predict a code, intercept an SMS, brute-force the OTP, or interfere with the SMS provider. The issue was entirely in how a valid verification result was reused afterward.
- 01Proof
OTP(Phone A) = validsession: verified - 02Intercept
billing_phone = Abilling_phone = Vsame session - 03Response
wordpress_logged_in_••••administrator V
4. Root cause
The vulnerable state represented only verified = true. For an authentication flow, the state also needs to preserve the identity that was actually verified.
01if ($submitted_otp === $stored_otp) {02 $_SESSION['verified_phone'] = normalize($phone_number);03}04 05if (06 normalize($_POST['billing_phone']) ===07 ($_SESSION['verified_phone'] ?? null)08) {09 // Continue authentication.10}The exact implementation can vary, but the invariant is simple: a successful OTP verification must remain bound to the phone number for which it was performed.
The state should also carry its purpose and expiry, for example verified_for and verified_at. That prevents a valid OTP result from drifting into another identity or an unrelated authentication path.
verified = trueverified_phone = Abilling_phone = Vbilling_phone = V5. Why the bug was easy to miss
Viewed independently, neither side looks especially suspicious. The OTP handler accepts a valid code and stores a success flag. The registration handler checks that a success flag exists before continuing. Both checks make sense locally.
The failure appears only when the state is followed across requests: verify(phone_A) produces a generic boolean, billing_phone changes, and authenticate(phone_V) consumes the old proof.
This is the review lesson I took from the bug. For any multi-request authentication flow, inspect not only whether proof exists, but also which identity, purpose, and lifetime that proof is bound to.
6. Impact and fix
The vulnerable path required no existing WordPress session. An attacker who knew or could guess the phone number associated with another account could verify a number they controlled, then reuse that session state while supplying the victim's number.
If the victim number belonged to an administrator, WordPress issued the authentication cookie for that administrator account. Wordfence classified the issue as CWE-288 and assigned CVSS 9.8: network reachable, low complexity, no privileges, and no user interaction.
SMS Alert versions up to and including 3.9.7 were affected. Version 3.9.8 fixed the issue. The key repair is to invalidate earlier OTP state when the phone number changes, or explicitly compare the current number with an identity-bound verified value before authentication.
I would also review every other consumer of sa_mobile_verified. Shared verification state deserves special attention when login, registration, checkout, and password-recovery paths all depend on it.
The vulnerability was reported to Wordfence and published as CVE-2026-15014, with Civitasmass credited as the researcher.