# RCA Report — CVE-2026-11387

## Summary

The SMS Alert – SMS & OTP for WooCommerce WordPress plugin (versions ≤ 3.9.5) contains an unauthenticated privilege escalation vulnerability in its OTP-based password reset handler. The `WPResetPassword::routeData()` method in `handler/forms/class-wpresetpassword.php` calls `handleSmsalertChangedPwd()` whenever the request parameter `option` equals `smsalert-change-password-form`, **without verifying that the OTP challenge was actually completed/validated**. Because `smsalert_site_challenge_otp()` sets `$_SESSION['user_login']` to the target user's login at OTP *initiation* time (before the OTP is sent), an attacker who triggers the OTP challenge for an administrator's phone number can immediately submit the password-change form and reset that administrator's password — bypassing OTP verification entirely. This leads to full account takeover and privilege escalation.

## Impact

- **Package:** SMS Alert – SMS & OTP for WooCommerce, Order Notifications & Abandoned Cart Recovery (slug: `sms-alert`)
- **Affected versions:** ≤ 3.9.5
- **Patched version:** 3.9.6
- **Risk level:** Critical (CVSS 9.8)
- **Consequences:** An unauthenticated remote attacker can reset any WordPress user's password (including administrators) when the SMS Alert plugin has OTP password-reset verification enabled and the target user has a `billing_phone` set. The attacker gains full administrative access to the WordPress site. The same class of vulnerability exists in the UltimateMember integration (`class-ultimatemember.php`, option `smsalert-um-reset-pwd-action`).

## Impact Parity

- **Disclosed/claimed maximum impact:** Unauthenticated privilege escalation via arbitrary password reset — attacker changes admin email/password and takes over the account.
- **Reproduced impact from this run:** Unauthenticated attacker resets the administrator's password directly (without needing email change) by exploiting the missing OTP validation check. The attacker can then log in as administrator with the new password.
- **Parity:** `full` — the core claimed impact (unauthenticated admin account takeover via arbitrary password reset) was demonstrated end-to-end against a running WordPress + WooCommerce + SMS Alert service.
- **Not demonstrated:** The advisory describes an email-change → standard-password-reset path as one exploitation variant. Our reproduction demonstrates a *more direct* path: the password itself is reset via `reset_password()` without any email change or OTP validation, which is the same root cause (missing identity/OTP-validation check in `routeData()`).

## Root Cause

### Vulnerable code (`class-wpresetpassword.php` v3.9.5)

```php
public function routeData()
{
    if (! empty($_REQUEST['option']) && sanitize_text_field(wp_unslash($_REQUEST['option'])) === 'smsalert-change-password-form' ) {
        $this->handleSmsalertChangedPwd($_POST);
    }
}
```

`routeData()` is called on every `init` hook (via `FormInterface::__construct()` → `add_action('init', ...)`) and is therefore reachable by unauthenticated users. When `option=smsalert-change-password-form` is present, `handleSmsalertChangedPwd()` is invoked, which reads `$_SESSION['user_login']` and calls WordPress's `reset_password($user, $new_password)` directly.

### How `$_SESSION['user_login']` gets set

The session variable is set by `smsalert_site_challenge_otp()` in `handler/smsalert_form_handler.php`:

```php
function smsalert_site_challenge_otp(...) {
    SmsAlertUtility::checkSession();
    $_SESSION['user_login']      = $user_login;   // <-- set at INITIATION, not validation
    $_SESSION['phone_number_mo'] = $phone_number;
    _handle_otp_action(...);                       // <-- sends OTP; may fail
}
```

This function is called from `WPResetPassword::startSmsalertResetPasswordProcess()` (hooked to WordPress's `lostpassword_post` action) when a user submits the lost-password form with a phone number and `wc_reset_password=true`. The session variables are set **before** the OTP is sent via the SMS Alert API. Even if the OTP API call fails (invalid credentials, network error), the session already contains the target user's login.

### Missing validation check

In the vulnerable version, `handle_post_verification()` (called after successful OTP validation) does **not** set `$_SESSION[$this->form_session_var]` to `'validated'`. And `routeData()` does **not** check for this value. Therefore, the password reset proceeds regardless of whether the OTP was ever validated.

### Fix (v3.9.6, changeset 3587983)

The patch adds two changes to `class-wpresetpassword.php`:

1. **`routeData()` now requires OTP validation:**
```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);
    }
}
```

2. **`handle_post_verification()` now marks the session as validated:**
```php
$_SESSION[ $this->form_session_var ] = 'validated';
```

The same fix pattern was applied to `class-ultimatemember.php` (option `smsalert-um-reset-pwd-action`, session var `form_session_var2`).

### 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

## Reproduction Steps

1. **Script:** `bundle/repro/reproduction_steps.sh`
2. **What the script does:**
   - Installs PHP, MariaDB, and wp-cli
   - Downloads and installs WordPress 7.0 + WooCommerce 10.9.3
   - Installs SMS Alert plugin 3.9.5 (vulnerable) and configures it: sets non-empty gateway credentials (so `is_user_authorised()` returns true), enables `reset_password=on`, and sets the admin user's `billing_phone` to `919999990001`
   - Starts a PHP built-in web server on `localhost:8080`
   - **Phase A (vulnerable):** Sends an unauthenticated POST to `wp-login.php?action=lostpassword` with `user_login=919999990001&wc_reset_password=true` — this triggers `lostpassword_post` → `startSmsalertResetPasswordProcess()` → `smsalert_site_challenge_otp()` which sets `$_SESSION['user_login']='admin'`. The OTP API call fails ("Wrong SMSAlert credentials") but the session is already poisoned. Then sends a second unauthenticated POST with `option=smsalert-change-password-form&smsalert_user_newpwd=Pwned!Pass42&smsalert_user_cnfpwd=Pwned!Pass42` — this calls `handleSmsalertChangedPwd()` which reads the session and calls `reset_password($admin, 'Pwned!Pass42')`. The server returns HTTP 302 redirect to `?password-reset=true`.
   - Verifies via `wp user check-password` that the new password works and the original does not
   - **Phase B (patched negative control):** Repeats the exact same attack against SMS Alert 3.9.6 — the password change is blocked (HTTP 200, no redirect), and the original password still works
3. **Expected evidence:**
   - Vulnerable 3.9.5: HTTP 302 with `Location: ?page_id=8&password-reset=true`; `wp user check-password admin 'Pwned!Pass42'` succeeds; original password fails
   - Patched 3.9.6: HTTP 200 (no redirect); `wp user check-password admin 'HackedPass99'` fails; original password still works

## Evidence

### Log file locations
- `bundle/logs/reproduction_steps.log` — full script output
- `bundle/logs/exploit_vuln_step1_response.html` — OTP challenge response (vulnerable)
- `bundle/logs/exploit_vuln_step2_headers.txt` — password change response headers showing 302 redirect
- `bundle/logs/exploit_vuln_cookies.txt` — session cookies used in the exploit
- `bundle/logs/exploit_fixed_step2_headers.txt` — patched version response (200, no redirect)
- `bundle/repro/runtime_manifest.json` — structured runtime evidence

### Key excerpts

**Vulnerable 3.9.5 — Step 2 response (password reset confirmed):**
```
HTTP/1.1 302 Found
X-Redirect-By: WordPress
Location: http://localhost:8080/?page_id=8&password-reset=true
```

**Vulnerable 3.9.5 — Password verification:**
```
New password 'Pwned!Pass42'       → OK   (expected: OK)
Old password 'admin_original_123' → FAIL (expected: FAIL)
```

**Patched 3.9.6 — Step 2 response (attack blocked):**
```
HTTP/1.1 200 OK
(no redirect — attack blocked)
```

**Patched 3.9.6 — Password verification:**
```
Hacked password 'HackedPass99'      → FAIL (expected: FAIL)
Original password 'admin_original_123' → OK   (expected: OK)
```

### Environment
- PHP 8.5.4 (built-in server)
- MariaDB 11.8.6
- WordPress 7.0
- WooCommerce 10.9.3
- SMS Alert 3.9.5 (vulnerable) / 3.9.6 (patched)

## Recommendations / Next Steps

1. **Upgrade immediately** to SMS Alert plugin version 3.9.6 or later.
2. **Audit all `routeData()` handlers** across the plugin's form classes for similar missing OTP-validation checks. The same pattern was found and fixed in `class-ultimatemember.php`.
3. **Defense in depth:** Consider adding nonce verification and capability checks to all password-modification endpoints, not just session-state checks.
4. **Testing:** Add integration tests that verify password reset requires a validated OTP session before allowing `handleSmsalertChangedPwd()` to execute.

## Additional Notes

- **Idempotency:** The script was run twice consecutively; both runs produced identical results (vulnerable exploit succeeded, patched control blocked). The script is designed to be idempotent — it reuses existing WordPress installs and reconfigures the plugin on each run.
- **Preconditions:** The vulnerability requires (a) OTP password reset enabled (`smsalert_general[reset_password]=on`), (b) the plugin authorized (`smsalert_gateway` credentials non-empty), (c) WooCommerce active, and (d) the target user having a `billing_phone` set. These match the advisory's stated preconditions.
- **No real SMS credentials needed:** The exploit works even when the SMS Alert API credentials are invalid, because `smsalert_site_challenge_otp()` sets the session variables before the OTP API call, and `handleSmsalertChangedPwd()` does not check OTP validation status.
- **Session cookie handling:** The exploit uses PHP's native session mechanism (`PHPSESSID` cookie). Both HTTP requests (OTP challenge + password change) must share the same `PHPSESSID` cookie.
