# Variant RCA Report — CVE-2026-11387 (UltimateMember alternate trigger)

## Summary

This variant run identified and **runtime-confirmed a materially different
entry point** for the same root cause as CVE-2026-11387: the **UltimateMember
integration** in the SMS Alert plugin (`handler/forms/class-ultimatemember.php`,
request option `smsalert-um-reset-pwd-action`, reached via UltimateMember's
`um_reset_password_process_hook` action). An unauthenticated attacker can reset an
administrator's password without completing OTP verification by (1) submitting
UltimateMember's password-reset form for the admin to poison
`$_SESSION['user_login']` at OTP *initiation* time, then (2) POSTing
`option=smsalert-um-reset-pwd-action` to call `handleSmsalertChangedPwd()` →
`reset_password()`. The 3.9.5 vulnerable code dispatches with **no** OTP-validation
check. **This is an alternate trigger, NOT a bypass:** the 3.9.6 fix adds the same
`$_SESSION['SA_UM_RESET_PWD'] === 'validated'` gate to the UM handler, and the
attack is blocked on 3.9.6. No bypass of the fix was found.

## Fix Coverage / Assumptions

- **Invariant the fix relies on:** the per-form session variable equals the literal
  `'validated'` only after a real OTP validation (`otp_verification_successful` →
  `handle_post_verification()`), and `handleSmsalertChangedPwd()` is gated on it.
- **Code paths explicitly covered:** `WPResetPassword::routeData()` (option
  `smsalert-change-password-form`, session var `wp_default_lost_pwd`) and
  `UltimateMember::handleForm()` (option `smsalert-um-reset-pwd-action`, session
  var `SA_UM_RESET_PWD`). Both were patched in 3.9.6 (changeset 3587983).
- **What the fix does NOT cover:** nothing for this vulnerability class. Only two
  handlers call `reset_password()`; both are gated. There is no separate
  email-change sink, and the two handlers use distinct session keys so no
  cross-handler confusion is possible. (See `patch_analysis.md` for the full
  exhaustive handler scan.)

## Variant / Alternate Trigger

**Entry point (materially different from the repro's wp-login/WooCommerce path):**

1. **Session poisoning** — Unauthenticated `POST` to the UltimateMember
   password-reset core page (e.g. `/?page_id=<um_pwd_page>`) with body
   `_um_password_reset=1&username_b=admin`. UltimateMember fires
   `um_reset_password_process_hook` → SMS Alert's
   `UltimateMember::smsalertUmResetPwdSubmitted()` (registered in `handleForm()`
   when `reset_password=on`). That calls `SmsAlertUtility::initialize_transaction()`
   (sets `$_SESSION['SA_UM_RESET_PWD']=true`) and `startOtpTransaction()` →
   `smsalert_site_challenge_otp()`, which sets `$_SESSION['user_login']='admin'`
   **before** the OTP API call. The OTP API call fails ("Wrong SMSAlert
   credentials") but the session is already poisoned.
2. **Password reset** — Unauthenticated `POST` to `/` with body
   `option=smsalert-um-reset-pwd-action&smsalert_user_newpwd=<pwd>&smsalert_user_cnfpwd=<pwd>`.
   On 3.9.5, `UltimateMember::handleForm()` matches the option and calls
   `handleSmsalertChangedPwd($_POST)`, which reads `$_SESSION['user_login']` and
   invokes WordPress's `reset_password($admin, $pwd)` with **no** OTP-validation
   check, then redirects to `?sa_um_reset_pwd=1`.

**Code path:** `POST ?page_id=<um_pwd> (_um_password_reset=1&username_b=admin)`
→ `um_reset_password_process_hook` → `UltimateMember::smsalertUmResetPwdSubmitted()`
→ `initialize_transaction('SA_UM_RESET_PWD')` + `startOtpTransaction()` →
`smsalert_site_challenge_otp()` sets `$_SESSION['user_login']` →
`POST ?option=smsalert-um-reset-pwd-action` → `UltimateMember::handleForm()`
→ `handleSmsalertChangedPwd()` → `reset_password($admin,$pwd)`.

**Files/functions:** `handler/forms/class-ultimatemember.php`
(`handleForm()` dispatch ~line 88 in 3.9.5; `smsalertUmResetPwdSubmitted()` ~line 337;
`handleSmsalertChangedPwd()` ~line 270; `handle_post_verification()` ~line 486),
`handler/smsalert_form_handler.php` (`smsalert_site_challenge_otp()` ~line 88,
`_handle_success_validated()` → `otp_verification_successful`).

**Preconditions** (all match the plugin's own enablement logic):
`smsalert_gateway` credentials non-empty; `smsalert_general[reset_password]=on`;
`smsalert_general[buyer_signup_otp]=on` (required for
`UltimateMember::isFormEnabled()`); target user has `billing_phone`; UltimateMember
active with a password-reset core page.

## Impact

- **Package:** SMS Alert – SMS & OTP for WooCommerce (slug `sms-alert`), UltimateMember integration
- **Affected versions:** ≤ 3.9.5 (alternate trigger); fixed in 3.9.6
- **Risk level:** Critical (CVSS 9.8) — unauthenticated admin account takeover
- **Consequences:** Unauthenticated remote attacker resets any user's password
  (including administrators) when the UM-based OTP reset flow is enabled, gaining
  full administrative access. Identical impact class to the disclosed CVE.

## Impact Parity

- **Disclosed/claimed maximum impact:** Unauthenticated privilege escalation via
  arbitrary password reset / account takeover.
- **Reproduced impact from this variant run:** Unauthenticated attacker reset the
  administrator's password directly via the UM entry point without OTP, then
  (verified via `wp user check-password`) the new password works and the original
  no longer does — full admin takeover on 3.9.5.
- **Parity:** `full` — same impact class and end-state as the parent CVE, via a
  different entry point.
- **Not demonstrated:** The advisory's email-change variant was not exercised
  (no separate email-change sink exists in the code; see `patch_analysis.md`).

## Root Cause

The same underlying bug as CVE-2026-11387: `smsalert_site_challenge_otp()` writes
`$_SESSION['user_login']` (and the per-form transaction var) at OTP *initiation*
time, **before** the OTP is sent/validated, while the password-change dispatch in
`UltimateMember::handleForm()` consumes that session state to call
`reset_password()` **without** requiring proof that the OTP was ever validated.
The 3.9.6 fix adds the `$_SESSION['SA_UM_RESET_PWD'] === 'validated'` gate (set
only in `handle_post_verification()` after real OTP success) to the UM dispatch,
closing the alternate trigger.

- **Fix commit/changeset:** https://plugins.trac.wordpress.org/changeset/3587983/sms-alert

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh`
2. **What the script does:**
   - Assumes the repro environment (MariaDB + PHP + wp-cli + WordPress + plugin
     zips at `bundle/artifacts/`) is present.
   - Installs/activates WooCommerce + UltimateMember; creates the UM
     password-reset core page and disables the UM reset-attempt limit.
   - **Phase A (vulnerable 3.9.5):** configures SMS Alert 3.9.5
     (`reset_password=on`, `buyer_signup_otp=on`, admin `billing_phone=919999990001`),
     starts the PHP server, runs the two-step UM attack, verifies via wp-cli that
     the admin password was changed to `UmPwned!Pass42`.
   - **Phase B (patched 3.9.6):** repeats the identical attack against 3.9.6 and
     verifies the password was **not** changed (original still works).
   - Writes `runtime_manifest.json` and `validation_verdict.json`.
3. **Expected evidence:**
   - 3.9.5: Step 2 returns `HTTP/1.1 302` with
     `Location: …?page_id=11&sa_um_reset_pwd=1`; `wp user check-password admin
     'UmPwned!Pass42'` → OK; original → FAIL.
   - 3.9.6: Step 2 returns `HTTP/1.1 200` (no redirect); `wp user check-password
     admin 'UmHacked!Pass99'` → FAIL; original → OK.

## Evidence

- `bundle/logs/vuln_variant_reproduction.log` — full script output (both runs)
- `bundle/logs/vv_vuln_step1_headers.txt` — 3.9.5 step 1 response: `Set-Cookie: PHPSESSID=…` (session poisoned)
- `bundle/logs/vv_vuln_step2_headers.txt` — 3.9.5 step 2 response:
  `HTTP/1.1 302 Found` / `Location: http://localhost:8072/?page_id=11&#038;sa_um_reset_pwd=1`
- `bundle/logs/vv_fixed_step2_headers.txt` — 3.9.6 step 2 response: `HTTP/1.1 200 OK` (no redirect → attack blocked)
- `bundle/logs/vv_vuln_cookies.txt`, `bundle/logs/vv_fixed_cookies.txt` — session cookies (PHPSESSID present in both; only 3.9.5 consumes the poisoned session)
- `bundle/vuln_variant/runtime_manifest.json`, `bundle/vuln_variant/validation_verdict.json`

**Key excerpts (3.9.5 — variant reproduced):**
```
[*] vuln Step 2: submit smsalert-um-reset-pwd-action (no OTP)...
    HTTP HTTP/1.1 302 Found
    Location: http://localhost:8072/?page_id=11&#038;sa_um_reset_pwd=1
[*] post-exploit: new 'UmPwned!Pass42' -> OK (expected OK)
[*] post-exploit: old 'admin_original_123'     -> FAIL (expected FAIL)
[+] VULNERABLE 3.9.5: UM variant SUCCEEDED
```
**Key excerpts (3.9.6 — attack blocked, NOT a bypass):**
```
[*] fixed Step 2: submit smsalert-um-reset-pwd-action (no OTP)...
    HTTP HTTP/1.1 200 OK
[*] post-exploit: new 'UmHacked!Pass99' -> FAIL (expected FAIL)
[*] post-exploit: old 'admin_original_123'       -> OK (expected OK)
[+] PATCHED 3.9.6: UM attack BLOCKED (NOT a bypass)
```

**Environment:** PHP 8.5.4 (built-in server), MariaDB (alive), WordPress latest,
WooCommerce, UltimateMember 2.12.0, SMS Alert 3.9.5 (vulnerable) / 3.9.6 (patched).
Server bound on `127.0.0.1:8072` (the repro's default 8080 was unavailable in this
run; the WP site URL was reconfigured to 8072).

## Recommendations / Next Steps

1. **No additional fix required for the UM path** — 3.9.6 already gates it. Sites
   must simply upgrade to ≥ 3.9.6.
2. **Defense in depth (applies to both handlers):** add a WordPress nonce check and
   a capability/identity re-check to the password-change dispatch, rather than
   relying solely on the session `'validated'` marker. Also clear
   `$_SESSION['user_login']` on OTP *failure* (currently the initiation-time value
   persists even when the OTP API errors).
3. **Audit remaining handlers** for any future "dispatch → privileged action
   without `validated` gate" pattern; the current exhaustive scan found none beyond
   the two already fixed.

## Additional Notes

- **Bypass vs alternate trigger:** This is an **alternate trigger** (different
  entry point, same root cause, same sink). It reproduces only on the vulnerable
  3.9.5; the 3.9.6 fix blocks it. It is **not** a bypass.
- **Idempotency:** The script was run twice consecutively; both runs produced
  identical results (3.9.5 variant succeeded, 3.9.6 blocked) and exited 1.
- **Note on `buyer_signup_otp`:** The UM handler's `isFormEnabled()` requires
  `buyer_signup_otp=on` (in addition to `reset_password=on`). Without it,
  `handleForm()` is never called and neither the hook nor the dispatch registers.
  The variant script sets both.
- **Negative-result breadth:** A full scan of all 53 form handlers confirmed only
  `class-wpresetpassword.php` and `class-ultimatemember.php` reach
  `reset_password()`; no other unauthenticated account-takeover sink exists.
