← All posts

CVE-2026-15014: Unbound OTP Verification State in SMS Alert

An unauthenticated account takeover caused by a verification boolean that survived an identity change.

Two overlapping profiles sharing one verification seal, with a shadow belonging to the other identity
A valid proof, detached from the identity it was meant to verify.

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.

Fig. 01The intended OTP path
Req.PurposeCarriesServer decisionState after
R1ChallengePhone AOTP issuedpending(A)
R2VerificationPhone A + valid codeCode acceptedverified = true
R3Authenticationbilling_phone = AResolve user Acookie(A)
Identity carried end to endA
Three locally reasonable steps. The security property depends on the identity remaining the same across all three.

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.

Simplified vulnerable statePHP
if ($submitted_otp === $stored_otp) {    $_SESSION['sa_mobile_verified'] = true;}

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.

Fig. 02Proof on one side, identity on the other
Session receipt · after R2proof accepted
sa_mobile_verified
true
verified_phone
not stored
next request
billing_phone
Phone V
resolved_user
administrator V
Consumer checks only the booleancookie → user V
The boolean proves that some OTP succeeded. The current request independently decides whose account receives the cookie.

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.

Fig. 03The identity changes; the proof survives
  1. 01ProofOTP(Phone A) = validsession: verified
  2. 02Interceptbilling_phone = Abilling_phone = Vsame session
  3. 03Responsewordpress_logged_in_••••administrator V
Recreated from the local test trace. Phone numbers, cookie values, hostnames, and unrelated headers are redacted.

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.

Conceptual identity-bound repairPHP
if ($submitted_otp === $stored_otp) {    $_SESSION['verified_phone'] = normalize($phone_number);} if (    normalize($_POST['billing_phone']) ===    ($_SESSION['verified_phone'] ?? null)) {    // Continue authentication.}

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.

Fig. 04The invariant the session must preserve
At consume timeBoolean stateIdentity-bound state
Stored proofverified = trueverified_phone = A
Request identitybilling_phone = Vbilling_phone = V
Comparisonproof existsA ≠ V
DecisionCookie for VReject
Authentication should continue only when the identity in the request equals the identity attached to the proof.

5. 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.

SourcesWordfence vulnerability recordNVD: CVE-2026-15014WordPress plugin source: registration flow

By Jing Qian (Civitasmass)Permanent link