{"repro_id":"REPRO-2026-00242","version":6,"title":"SMS Alert WordPress plugin <= 3.9.5 allows unauthenticated attackers to change a user’s email and reset their password, leading to account takeover and privilege escalation when OTP password reset verification is enabled.","repro_type":"security","status":"published","severity":"critical","cvss_score":9.8,"description":"The SMS Alert – SMS & OTP for WooCommerce, Order Notifications & Abandoned Cart Recovery plugin for WordPress fails to properly validate a user’s identity before updating account details during password reset flows. An unauthenticated attacker can change arbitrary users’ email addresses (including administrators), then trigger a password reset to take over the account.","root_cause":"# RCA Report — CVE-2026-11387\n\n## Summary\n\nThe 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.\n\n## Impact\n\n- **Package:** SMS Alert – SMS & OTP for WooCommerce, Order Notifications & Abandoned Cart Recovery (slug: `sms-alert`)\n- **Affected versions:** ≤ 3.9.5\n- **Patched version:** 3.9.6\n- **Risk level:** Critical (CVSS 9.8)\n- **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`).\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Unauthenticated privilege escalation via arbitrary password reset — attacker changes admin email/password and takes over the account.\n- **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.\n- **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.\n- **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()`).\n\n## Root Cause\n\n### Vulnerable code (`class-wpresetpassword.php` v3.9.5)\n\n```php\npublic function routeData()\n{\n    if (! empty($_REQUEST['option']) && sanitize_text_field(wp_unslash($_REQUEST['option'])) === 'smsalert-change-password-form' ) {\n        $this->handleSmsalertChangedPwd($_POST);\n    }\n}\n```\n\n`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.\n\n### How `$_SESSION['user_login']` gets set\n\nThe session variable is set by `smsalert_site_challenge_otp()` in `handler/smsalert_form_handler.php`:\n\n```php\nfunction smsalert_site_challenge_otp(...) {\n    SmsAlertUtility::checkSession();\n    $_SESSION['user_login']      = $user_login;   // <-- set at INITIATION, not validation\n    $_SESSION['phone_number_mo'] = $phone_number;\n    _handle_otp_action(...);                       // <-- sends OTP; may fail\n}\n```\n\nThis 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.\n\n### Missing validation check\n\nIn 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.\n\n### Fix (v3.9.6, changeset 3587983)\n\nThe patch adds two changes to `class-wpresetpassword.php`:\n\n1. **`routeData()` now requires OTP validation:**\n```php\npublic function routeData()\n{\n    SmsAlertUtility::checkSession();\n    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 ) {\n        $this->handleSmsalertChangedPwd($_POST);\n    }\n}\n```\n\n2. **`handle_post_verification()` now marks the session as validated:**\n```php\n$_SESSION[ $this->form_session_var ] = 'validated';\n```\n\nThe same fix pattern was applied to `class-ultimatemember.php` (option `smsalert-um-reset-pwd-action`, session var `form_session_var2`).\n\n### Fix reference\n- WordPress.org changeset: https://plugins.trac.wordpress.org/changeset/3587983/sms-alert\n- Wordfence advisory: https://www.wordfence.com/threat-intel/vulnerabilities/id/c31906da-f2fd-40ac-86e0-3f1ed0409d0c\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh`\n2. **What the script does:**\n   - Installs PHP, MariaDB, and wp-cli\n   - Downloads and installs WordPress 7.0 + WooCommerce 10.9.3\n   - 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`\n   - Starts a PHP built-in web server on `localhost:8080`\n   - **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`.\n   - Verifies via `wp user check-password` that the new password works and the original does not\n   - **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\n3. **Expected evidence:**\n   - 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\n   - Patched 3.9.6: HTTP 200 (no redirect); `wp user check-password admin 'HackedPass99'` fails; original password still works\n\n## Evidence\n\n### Log file locations\n- `bundle/logs/reproduction_steps.log` — full script output\n- `bundle/logs/exploit_vuln_step1_response.html` — OTP challenge response (vulnerable)\n- `bundle/logs/exploit_vuln_step2_headers.txt` — password change response headers showing 302 redirect\n- `bundle/logs/exploit_vuln_cookies.txt` — session cookies used in the exploit\n- `bundle/logs/exploit_fixed_step2_headers.txt` — patched version response (200, no redirect)\n- `bundle/repro/runtime_manifest.json` — structured runtime evidence\n\n### Key excerpts\n\n**Vulnerable 3.9.5 — Step 2 response (password reset confirmed):**\n```\nHTTP/1.1 302 Found\nX-Redirect-By: WordPress\nLocation: http://localhost:8080/?page_id=8&password-reset=true\n```\n\n**Vulnerable 3.9.5 — Password verification:**\n```\nNew password 'Pwned!Pass42'       → OK   (expected: OK)\nOld password 'admin_original_123' → FAIL (expected: FAIL)\n```\n\n**Patched 3.9.6 — Step 2 response (attack blocked):**\n```\nHTTP/1.1 200 OK\n(no redirect — attack blocked)\n```\n\n**Patched 3.9.6 — Password verification:**\n```\nHacked password 'HackedPass99'      → FAIL (expected: FAIL)\nOriginal password 'admin_original_123' → OK   (expected: OK)\n```\n\n### Environment\n- PHP 8.5.4 (built-in server)\n- MariaDB 11.8.6\n- WordPress 7.0\n- WooCommerce 10.9.3\n- SMS Alert 3.9.5 (vulnerable) / 3.9.6 (patched)\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to SMS Alert plugin version 3.9.6 or later.\n2. **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`.\n3. **Defense in depth:** Consider adding nonce verification and capability checks to all password-modification endpoints, not just session-state checks.\n4. **Testing:** Add integration tests that verify password reset requires a validated OTP session before allowing `handleSmsalertChangedPwd()` to execute.\n\n## Additional Notes\n\n- **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.\n- **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.\n- **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.\n- **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.\n","cve_id":"CVE-2026-11387","cwe_id":"CWE-287 (Improper Authentication)","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-11387","package":{"name":"sms-alert","ecosystem":"WordPress plugin (hosted on WordPress.org SVN, not GitHub)","affected_versions":"<= 3.9.5","fixed_version":"3.9.6"},"reproduced_at":"2026-07-06T08:32:50.450463+00:00","duration_secs":1376.0,"tool_calls":321,"handoffs":2,"total_cost_usd":7.04575457,"agent_costs":{"hypothesis_generator":0.0114056,"judge":0.01703265,"repro":2.33576013,"support":0.08970246000000001,"vuln_variant":4.5918537299999995},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0114056},"judge":{"gpt-5.4-mini":0.01703265},"repro":{"accounts/fireworks/routers/glm-5p2-fast":2.33576013},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.08970246000000001},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":4.5918537299999995}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-06T08:33:21.415040+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16128,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10479,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16673,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":10931,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":13675,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1937,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":2364,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1092,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1203,"category":"other"},{"path":"bundle/logs/exploit_vuln_step2_headers.txt","filename":"exploit_vuln_step2_headers.txt","size":365,"category":"other"},{"path":"bundle/logs/exploit_fixed_step2_headers.txt","filename":"exploit_fixed_step2_headers.txt","size":352,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":4278,"category":"log"},{"path":"bundle/logs/patch_diff_wpresetpassword.txt","filename":"patch_diff_wpresetpassword.txt","size":1128,"category":"other"},{"path":"bundle/logs/exploit_vuln_step1_response.html","filename":"exploit_vuln_step1_response.html","size":30230,"category":"other"},{"path":"bundle/logs/exploit_vuln_step2_response.html","filename":"exploit_vuln_step2_response.html","size":0,"category":"other"},{"path":"bundle/logs/exploit_vuln_cookies.txt","filename":"exploit_vuln_cookies.txt","size":278,"category":"other"},{"path":"bundle/logs/exploit_fixed_step1_response.html","filename":"exploit_fixed_step1_response.html","size":30234,"category":"other"},{"path":"bundle/logs/exploit_fixed_step2_response.html","filename":"exploit_fixed_step2_response.html","size":74436,"category":"other"},{"path":"bundle/logs/exploit_fixed_cookies.txt","filename":"exploit_fixed_cookies.txt","size":278,"category":"other"},{"path":"bundle/logs/php-server.log","filename":"php-server.log","size":603,"category":"log"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":7606,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4449,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1274,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1620,"category":"other"},{"path":"bundle/logs/vv_vuln_step2_headers.txt","filename":"vv_vuln_step2_headers.txt","size":369,"category":"other"},{"path":"bundle/logs/vv_fixed_step2_headers.txt","filename":"vv_fixed_step2_headers.txt","size":352,"category":"other"},{"path":"bundle/logs/vuln_variant_reproduction.log","filename":"vuln_variant_reproduction.log","size":2215,"category":"log"},{"path":"bundle/logs/vv_vuln_step1.html","filename":"vv_vuln_step1.html","size":34288,"category":"other"},{"path":"bundle/logs/vv_vuln_step1_headers.txt","filename":"vv_vuln_step1_headers.txt","size":710,"category":"other"},{"path":"bundle/logs/vv_vuln_step2.html","filename":"vv_vuln_step2.html","size":0,"category":"other"},{"path":"bundle/logs/vv_vuln_cookies.txt","filename":"vv_vuln_cookies.txt","size":200,"category":"other"},{"path":"bundle/logs/vv_fixed_step1.html","filename":"vv_fixed_step1.html","size":34288,"category":"other"},{"path":"bundle/logs/vv_fixed_step1_headers.txt","filename":"vv_fixed_step1_headers.txt","size":710,"category":"other"},{"path":"bundle/logs/vv_fixed_step2.html","filename":"vv_fixed_step2.html","size":83102,"category":"other"},{"path":"bundle/logs/vv_fixed_cookies.txt","filename":"vv_fixed_cookies.txt","size":200,"category":"other"},{"path":"bundle/logs/vv-php-server.log","filename":"vv-php-server.log","size":4306,"category":"log"}]}