# Patch Analysis — CVE-2026-11387 (SMS Alert WordPress plugin)

## Target threat-model scope

The SMS Alert plugin (`sms-alert` slug) is a WordPress plugin that adds SMS/OTP
verification to WooCommerce and third-party membership/form plugins. There is no
`SECURITY.md` in the plugin package; the vendor's security scope is implicitly
"unauthenticated remote attackers must not be able to perform authenticated
actions (password reset / account takeover) via the plugin's OTP-gated flows."
The advisory (CVE-2026-11387 / Wordfence c31906da-…) classifies the issue as
**unauthenticated privilege escalation** (CVSS 9.8), which is in scope.

## What the fix changes (3.9.5 → 3.9.6, changeset 3587983)

The patch applies the **same two-part pattern** to two form-handler classes that
both perform password reset:

### 1. `handler/forms/class-wpresetpassword.php` (WooCommerce / wp-login flow)

**`routeData()` — before (3.9.5):**
```php
public function routeData() {
    if (! empty($_REQUEST['option']) && sanitize_text_field(wp_unslash($_REQUEST['option'])) === 'smsalert-change-password-form' ) {
        $this->handleSmsalertChangedPwd($_POST);   // NO OTP-validation check
    }
}
```
**`routeData()` — after (3.9.6):**
```php
public function routeData() {
    SmsAlertUtility::checkSession();
    if (! empty($_REQUEST['option']) && (sanitize_text_field(wp_unslash($_REQUEST['option'])) === 'smsalert-change-password-form') && isset($_SESSION[ $this->form_session_var ]) && strcasecmp($_SESSION[ $this->form_session_var ], 'validated') === 0 ) {
        $this->handleSmsalertChangedPwd($_POST);
    }
}
```

**`handle_post_verification()` — after (3.9.6) gains:**
```php
$_SESSION[ $this->form_session_var ] = 'validated';   // marks OTP as validated
```
(`$this->form_session_var = FormSessionVars::WP_DEFAULT_LOST_PWD = 'wp_default_lost_pwd'`.)

### 2. `handler/forms/class-ultimatemember.php` (UltimateMember flow)

**`handleForm()` dispatch — before (3.9.5):**
```php
if (! empty($_REQUEST['option']) && sanitize_text_field(wp_unslash($_REQUEST['option'])) === 'smsalert-um-reset-pwd-action' ) {
    $this->handleSmsalertChangedPwd($_POST);   // NO OTP-validation check
}
```
**`handleForm()` dispatch — after (3.9.6):**
```php
SmsAlertUtility::checkSession();
if (! empty($_REQUEST['option']) && (sanitize_text_field(wp_unslash($_REQUEST['option'])) === 'smsalert-um-reset-pwd-action') && isset($_SESSION[ $this->form_session_var2 ]) && strcasecmp($_SESSION[ $this->form_session_var2 ], 'validated') === 0 ) {
    $this->handleSmsalertChangedPwd($_POST);
}
```

**`handle_post_verification()` — after (3.9.6) gains:**
```php
if (isset($_SESSION[ $this->form_session_var2 ])) {
    $_SESSION[ $this->form_session_var2 ] = 'validated';
    smsalertAskForResetPassword(...);
}
```
(`$this->form_session_var2 = 'SA_UM_RESET_PWD'` — a distinct, hardcoded session
key, NOT a `FormSessionVars` constant.)

## The invariant the fix relies on

> **The per-form session variable is set to the literal string `'validated'` ONLY
> after a genuinely successful OTP validation, and the privileged
> `handleSmsalertChangedPwd()` → `reset_password()` sink is gated on that value.**

The `'validated'` marker is written exclusively inside `handle_post_verification()`,
which is registered on the `otp_verification_successful` action by
`FormInterface::__construct()`. That action is fired by
`_handle_success_validated()` in `handler/smsalert_form_handler.php` only when the
SMS Alert API returns `status=success` and `desc='Code Matched successfully.'`
— i.e. only when the requester supplies the correct OTP token that was sent to the
target user's `billing_phone`.

At OTP **initiation** time, `smsalert_site_challenge_otp()` sets
`$_SESSION['user_login']` (and `$_SESSION['SA_UM_RESET_PWD']` via
`SmsAlertUtility::initialize_transaction()` → value `true`, or `wp_default_lost_pwd`
via `initialize_transaction`) **before** the OTP API call. The 3.9.5 bug was that
the dispatch consumed this initiation-time session state without ever requiring
the post-validation `'validated'` marker. The 3.9.6 fix closes that gap.

## Code paths the fix explicitly covers

| Entry point | Option value | Handler method | Session var checked | Fixed in 3.9.6 |
|---|---|---|---|---|
| WooCommerce / wp-login lost-password | `smsalert-change-password-form` | `WPResetPassword::routeData()` | `wp_default_lost_pwd` | ✅ |
| UltimateMember reset password | `smsalert-um-reset-pwd-action` | `UltimateMember::handleForm()` | `SA_UM_RESET_PWD` | ✅ |

## What the fix does NOT cover (analysis of remaining gaps)

I exhaustively searched the plugin's 53 form handlers for the same
"dispatch → privileged sink without OTP-validation gate" pattern:

1. **Only two handlers call `reset_password()`:** `class-wpresetpassword.php` and
   `class-ultimatemember.php`. Both are now gated. **No third password-reset sink
   exists.**
2. **No separate email-change sink exists.** The advisory's prose mentions an
   "email change → standard password reset" path, but `handleSmsalertChangedPwd()`
   only changes the password (it calls WP's `reset_password($user, $new_password)`).
   A codebase-wide search for unauthenticated `user_email`/`wp_update_user` email
   mutation found only new-user-registration paths (`signup-with-otp.php`,
   `wc-registration.php`), which create *new* accounts (a different trust scenario)
   and require their own OTP transaction.
3. **Cross-handler session confusion is not possible** for the two reset sinks
   because they use **distinct** session keys (`wp_default_lost_pwd` vs
   `SA_UM_RESET_PWD`), and each dispatch checks only its own key. Validating OTP
   through one handler cannot satisfy the other handler's gate.
4. **The `'validated'` marker is only written by `handle_post_verification()`**
   after real OTP success; a codebase search found no other assignment to either
   `wp_default_lost_pwd` or `SA_UM_RESET_PWD` equal to `'validated'`. So there is
   no alternate path that forges the gate value without OTP.
5. **Other `routeData()` handlers** (wplogin, contactform7, affiliatemanager,
   wpmember, easyregistration, wc-registration, wc-checkout) dispatch to OTP
   generate/validate/login/registration flows — none reach a password-reset or
   account-takeover sink.

**Conclusion:** The 3.9.6 fix is **complete** for the disclosed vulnerability
class. The UltimateMember entry point is a *distinct alternate trigger* for the
*same* root cause, and it **is covered** by the fix (verified at runtime in this
variant run: the UM attack is blocked on 3.9.6). No bypass was found.

## Behavior before vs after the fix

| Scenario | 3.9.5 (vulnerable) | 3.9.6 (fixed) |
|---|---|---|
| Initiate OTP for admin phone (sets `$_SESSION['user_login']` + `form_session_var=true`) | Session poisoned | Session poisoned (initiation unchanged) |
| Submit `option=smsalert-(um-)reset-pwd-action` **without** OTP | `handleSmsalertChangedPwd()` runs → `reset_password()` → admin password changed (302 to `password-reset=true` / `sa_um_reset_pwd=1`) | Dispatch gate fails (`form_session_var !== 'validated'`) → `handleSmsalertChangedPwd()` NOT called → HTTP 200, password unchanged |
| Submit after **real** OTP validation | Password changed (legitimate) | Password changed (legitimate — `handle_post_verification` set `'validated'`) |

## Fix reference

- WordPress.org changeset: https://plugins.trac.wordpress.org/changeset/3587983/sms-alert
- Wordfence advisory: https://www.wordfence.com/threat-intel/vulnerabilities/id/c31906da-f2fd-40ac-86e0-3f1ed0409d0c
