CVE-2026-61967 — miniOrange OTP Verification Ultimate Member Password Reset Authentication Bypass

1. Overview

A vulnerability exists in the miniOrange OTP Verification plugin for WordPress (versions 5.5.1 and earlier) that allows an unauthenticated attacker to bypass OTP verification during the Ultimate Member password reset flow. The plugin’s um_reset_password_process_hook handler processes password reset requests without checking whether the required OTP was actually validated, relying solely on a publicly available WordPress nonce for authorization. An attacker can submit the password reset form for any user account, including administrators, and receive a valid password reset URL in the HTTP response, enabling full account takeover without any OTP or credential. The vendor addressed this vulnerability in version 5.5.2 by adding OTP session validation and username integrity checks.


2. Vulnerability Type

FieldValue
Primary CWECWE-640: Weak Password Recovery Mechanism for Forgotten Password
Related CWECWE-306: Missing Authentication for Critical Function
Related CWECWE-287: Improper Authentication

3. Severity

CVSS 3.1 (from Patchstack Advisory)

FieldValue
Score9.8 (Critical)
VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Our Assessment (CVSS 4.0)

Metric GroupMetricValue
Base — ExploitabilityAttack Vector (AV)Network
Attack Complexity (AC)Low
Attack Requirements (AT)Present
Privileges Required (PR)None
User Interaction (UI)None
Base — Vulnerable SystemConfidentiality (VC)High
Integrity (VI)High
Availability (VA)High
Base — Subsequent SystemConfidentiality (SC)None
Integrity (SI)None
Availability (SA)None
ThreatExploit Maturity (E)Proof-of-Concept
FieldValue
CVSS 4.0 Score8.2 (High)
CVSS 4.0 VectorCVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P

AT:P rationale: The attack requires Ultimate Member plugin to be installed alongside miniOrange OTP Verification, and the UM Password Reset OTP form must be enabled in the plugin settings. While this is a common configuration for sites using both plugins, it is not a default WordPress state.


4. Affected Products

Affected Software

ProductVersionCPE 2.3
miniOrange OTP Verification (WordPress Plugin)<= 5.5.1cpe:2.3:a:miniorange:otp_verification:*:*:*:*:*:wordpress:*:*

Prerequisite plugins:

  • Ultimate Member (any version with password reset functionality)

Tested Environment (Vulnerable)

FieldValue
PlatformWordPress 6.5.5 on PHP 8.2.21 (Apache/Debian)
Pluginminiorange-otp-verification 5.5.1
Dependencyultimate-member 2.12.1
Plugin Filehandler/forms/class-moumpasswordreset.php
ContainerPodman, docker.io/library/wordpress:6.5-php8.2-apache

Tested Environment (Patched)

FieldValue
PlatformWordPress 6.5.5 on PHP 8.2.21 (Apache/Debian)
Pluginminiorange-otp-verification 5.5.2
Dependencyultimate-member 2.12.1
Plugin Filehandler/forms/class-moumpasswordreset.php
ContainerPodman, docker.io/library/wordpress:6.5-php8.2-apache

5. Root Cause Analysis

5a. Detailed Description

The miniOrange OTP Verification plugin integrates with Ultimate Member’s password reset form by hooking into um_reset_password_process_hook at priority 1. This hook fires when a user submits the Ultimate Member password reset form after the error-checking phase completes.

In the intended flow, the user enters their username, receives an OTP via email or SMS, validates the OTP through an AJAX call (mo_umpr_validate_otp), and only then does the JavaScript submit the UM form. The OTP validation sets a session variable (VALIDATED status) that should be checked before processing the password reset.

The vulnerable code (version 5.5.1) skips the OTP validation check entirely. The um_reset_password_process_hook() function only verifies a WordPress nonce (form_nonce) before processing the password reset:

// handler/forms/class-moumpasswordreset.php — um_reset_password_process_hook()
// VULNERABLE VERSION (5.5.1)

public function um_reset_password_process_hook() {
    // Only check: WordPress nonce — publicly available in page JavaScript
    if ( ! isset( $_POST['security'] ) ||
         ! wp_verify_nonce(
             sanitize_text_field( wp_unslash( $_POST['security'] ) ),
             'form_nonce'
         )
    ) {
        return;
    }

    // BUG: No check for OTP initialization or validation!
    // Takes ANY username from POST and processes password reset directly
    $user    = MoUtility::sanitize_check( 'username_b', $_POST );
    $user    = $this->get_user( trim( $user ) );
    $pwd_obj = $this->get_um_pwd_obj();
    um_fetch_user( $user->ID );
    $this->get_um_user_obj()->password_reset();  // Generates reset hash
    wp_safe_redirect( $pwd_obj->reset_url() );   // Returns reset URL to attacker
    exit();
}

The form_nonce WordPress nonce is embedded in the page’s JavaScript via wp_localize_script():

// moumprvar is publicly readable by any page visitor
var moumprvar = {
    "siteURL": "http://target/wp-admin/admin-ajax.php",
    "nonce": "90f362f871",  // ← This is the form_nonce value
    "action": {"send": "mo_umpr_send_otp"},
    "vaction": "mo_umpr_validate_otp",
    // ...
};

An unauthenticated attacker can:

  1. GET the Ultimate Member password reset page to extract moumprvar.nonce
  2. POST to the same page with username_b=admin, security=<nonce>, and _um_password_reset=1
  3. The handler fires without any OTP check, calls password_reset(), and returns a 302 redirect containing a valid password reset hash in the URL

The attacker receives the redirect with ?act=reset_password&hash=<valid_hash>&login=admin, giving them direct access to set a new password for the targeted account.

5b. Vulnerable Code Path

HTTP POST → WordPress → do_action('um_reset_password_process_hook')
  → MoUMPasswordReset::um_reset_password_process_hook()
    → wp_verify_nonce('form_nonce')           ← PASSES (nonce from page JS)
    → [NO OTP CHECK]                          ← VULNERABILITY
    → $this->get_user('admin')                ← attacker-controlled username
    → UM()->user()->password_reset()          ← generates valid reset hash
    → wp_safe_redirect(reset_url())           ← returns hash to attacker

The hook is registered at priority 1 via:

add_action('um_reset_password_process_hook', array($this, 'um_reset_password_process_hook'), 1);

Both wp_ajax_nopriv_mo_umpr_send_otp and wp_ajax_nopriv_mo_umpr_validate_otp AJAX endpoints are registered but the actual form processing hook does not depend on their prior invocation.

5c. Fix (Patched Version — 5.5.2)

The patched version adds three security checks before processing the password reset:

// PATCHED VERSION (5.5.2)

public function um_reset_password_process_hook() {
    if ( ! isset( $_POST['security'] ) || ! wp_verify_nonce( ... ) ) {
        return;
    }

    // FIX 1: Verify OTP was initialized AND validated
    $otp_ver_type = $this->get_verification_type();
    if ( ! SessionUtils::is_otp_initialized( $this->form_session_var )
        || ! SessionUtils::is_status_match(
               $this->form_session_var, self::VALIDATED, $otp_ver_type
           )
    ) {
        return;  // Block: OTP not validated
    }

    // FIX 2: Verify posted username matches session username
    $posted_username  = MoUtility::sanitize_check( 'username_b', $_POST );
    $session_username = SessionUtils::get_user_submitted( $this->form_session_var );
    if ( MoUtility::is_blank( $session_username )
        || $session_username !== trim( $posted_username )
    ) {
        return;  // Block: username mismatch (prevents target switching)
    }

    // FIX 3: Clear OTP session to prevent replay
    $this->unset_otp_session_variables();

    $user = $this->get_user( trim( $posted_username ) );
    // ... rest of password reset processing
}
CheckVulnerable (5.5.1)Patched (5.5.2)
WordPress nonceYesYes
OTP initializedNoYes — is_otp_initialized()
OTP validatedNoYes — is_status_match(VALIDATED)
Username integrityNoYes — session vs POST comparison
Session cleanupNoYes — unset_otp_session_variables()

Secondary fix in class-wploginform.php: The mo_by_pass_login() function was restructured to correctly handle admin OTP bypass in 2FA mode (by_pass_admin setting). Previously, admin bypass only worked in OTP-only mode (skip_password_check=true); the fix extends it to 2FA mode where the password was already verified.

5d. Impact

The vulnerability allows an unauthenticated remote attacker to reset the password of any WordPress user account, including administrators. Upon successful exploitation, the attacker receives a valid password reset URL containing a secret hash in the HTTP 302 redirect response. Using this URL, the attacker can set a new password for the targeted account, achieving full account takeover. For administrator accounts, this leads to complete WordPress site compromise including the ability to install backdoors, modify content, access sensitive data, and pivot to other services sharing credentials.

The attack requires no user interaction, no valid credentials, and no knowledge of the OTP. The only prerequisite is that the target site uses both Ultimate Member and miniOrange OTP Verification with the UM Password Reset form enabled — a common configuration for sites that implement phone/email OTP verification on their membership system. The nonce required for the exploit is publicly embedded in the page’s JavaScript, making it trivially extractable by any visitor.


6. Proof-of-Concept

6a. PoC Code

FileDescription
poc.pyPython 3 exploit script — extracts nonce and triggers password reset bypass

6b. Reproduce Instructions

Prerequisites:

  • WordPress site with Ultimate Member and miniOrange OTP Verification <= 5.5.1
  • UM Password Reset OTP form enabled (mo_um_pr_pass_enable = 1)
  • Python 3 with requests library

Container-based test environment setup:

# Create network and database
podman network create wp-net
podman run -d --name wp-db --network wp-net \
  -e MYSQL_ROOT_PASSWORD=rootpass -e MYSQL_DATABASE=wordpress \
  -e MYSQL_USER=wpuser -e MYSQL_PASSWORD=wppass \
  docker.io/library/mysql:8.0

# Start WordPress
podman run -d --name wp-vuln --network wp-net -p 8888:80 \
  -e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wpuser \
  -e WORDPRESS_DB_PASSWORD=wppass -e WORDPRESS_DB_NAME=wordpress \
  docker.io/library/wordpress:6.5-php8.2-apache

# Install WordPress, plugins, and configure
podman exec wp-vuln bash -c "
  curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
  chmod +x wp-cli.phar && mv wp-cli.phar /usr/local/bin/wp
  apt-get update -qq && apt-get install -y -qq unzip > /dev/null 2>&1
  wp core install --url=http://localhost:8888 --title=Test \
    --admin_user=admin --admin_password=admin123 \
    --admin_email=admin@test.com --allow-root --skip-email
  wp plugin install ultimate-member --activate --allow-root
"

# Copy vulnerable plugin and activate
podman cp miniorange-otp-verification-5.5.1.zip wp-vuln:/tmp/mo-otp.zip
podman exec wp-vuln bash -c "
  cd /var/www/html/wp-content/plugins && unzip -qo /tmp/mo-otp.zip
"
podman exec wp-vuln wp plugin activate miniorange-otp-verification --allow-root

# Enable UM Password Reset OTP and create page
podman exec wp-vuln wp option update mo_um_pr_pass_enable 1 --allow-root
podman exec wp-vuln wp option update mo_um_pr_enabled_type mo_um_email_enable --allow-root
podman exec wp-vuln wp eval '
  $pid = wp_insert_post(["post_title"=>"Password Reset",
    "post_content"=>"[ultimatemember_password]",
    "post_status"=>"publish","post_type"=>"page"]);
  update_option("um_reset_password_page", $pid);
' --allow-root
podman exec wp-vuln wp rewrite structure '/%postname%/' --allow-root

Exploit steps:

  1. Run the PoC:

    python3 poc.py http://localhost:8888 admin
    
  2. Expected output:

    [1] Finding UM password reset page...
    [+] Found: http://localhost:8888/password-reset/
    [2] Extracting form_nonce from moumprvar JavaScript...
    [+] Nonce: 90f362f871
    [3] Submitting password reset for 'admin' (bypassing OTP)...
        HTTP 302
    
    ============================================================
      EXPLOIT SUCCESSFUL - OTP VERIFICATION BYPASSED
    ============================================================
      User:       admin
      Reset hash: rv9qWC7ce3O9pGjDgP6n
      Reset URL:  http://localhost:8888/password-reset/?act=reset_password&hash=rv9qWC7ce3O9pGjDgP6n&login=admin
    ============================================================
    
  3. Visit the returned reset URL to set a new password for the admin account.

6c. Test Results

MetricVulnerable (5.5.1)Patched (5.5.2)
HTTP response code302302
Redirect location?act=reset_password&hash=<valid>&login=admin?updated=checkemail
Reset hash returnedYesNo
OTP requiredNo (bypassed)Yes (enforced)
Account takeover possibleYesNo

6d. Patched System Verification

Running the same PoC against the patched version (5.5.2):

[1] Finding UM password reset page...
[+] Found: http://localhost:8889/password-reset/
[2] Extracting form_nonce from moumprvar JavaScript...
[+] Nonce: f151072e60
[3] Submitting password reset for 'admin' (bypassing OTP)...
    HTTP 302

[-] No password reset hash returned.
[-] The OTP validation check blocked the reset (fix is in place).

The patched version returns a redirect to ?updated=checkemail (UM’s default “check your email” flow) without generating a password reset hash. The SessionUtils::is_otp_initialized() check returns false because no OTP session was created, causing the handler to return before reaching the password reset logic.


7. Detection

Section 7A: Network-Based Detection

Signature-Based Detection

The attack involves a POST request to the Ultimate Member password reset page containing _um_password_reset=1, username_b=<target>, and security=<nonce>. Legitimate password reset submissions also contain these fields, but they are preceded by AJAX calls to mo_umpr_send_otp and mo_umpr_validate_otp. The exploit skips these AJAX calls entirely.

Detection approaches:

  1. Rate-based: Multiple password reset form submissions from the same source IP within a short window
  2. Pattern-based: POST to a page containing _um_password_reset without a preceding mo_umpr_validate_otp AJAX call in the same session
  3. Response-based: HTTP 302 response to a password reset POST containing hash= and login= in the Location header

Suricata Rules

# Detect rapid UM password reset form submissions (potential CVE-2026-61967 exploitation)
alert http $EXTERNAL_NET any -> $HOME_NET any ( \
  msg:"CVE-2026-61967 miniOrange OTP Bypass - UM Password Reset Spray"; \
  flow:to_server,established; \
  http.method; content:"POST"; \
  http.request_body; content:"_um_password_reset=1"; \
  http.request_body; content:"username_b="; \
  http.request_body; content:"security="; \
  threshold:type both, track by_src, count 3, seconds 60; \
  reference:cve,2026-61967; \
  classtype:web-application-attack; \
  sid:2026061967; rev:1;)

# Detect successful exploitation via password reset hash in redirect
alert http $HOME_NET any -> $EXTERNAL_NET any ( \
  msg:"CVE-2026-61967 miniOrange OTP Bypass - Reset Hash Leaked in Redirect"; \
  flow:to_client,established; \
  http.stat_code; content:"302"; \
  http.header; content:"Location"; \
  http.header; content:"act=reset_password"; \
  http.header; content:"hash="; \
  http.header; content:"login="; \
  reference:cve,2026-61967; \
  classtype:web-application-attack; \
  sid:2026061968; rev:1;)

Section 7B: Host-Based Detection

Version Identification

Check the installed plugin version:

grep -oP "Version:\s*\K[0-9.]+" /var/www/html/wp-content/plugins/miniorange-otp-verification/miniorange_validation_settings.php

Versions <= 5.5.1 are vulnerable.

Service/Application Logs

The exploit produces a distinctive access log pattern: a POST to the UM password reset page without preceding AJAX calls to mo_umpr_validate_otp. In the legitimate flow, four requests appear in sequence — the page GET, the OTP send AJAX, the OTP validate AJAX, then the form POST. The exploit skips the middle two.

Detect password reset POSTs not preceded by OTP validation:

# Apache/Nginx combined log — find reset form POSTs that returned a 302 redirect
grep -P 'POST.*(password-reset|um-password-reset).*\s30[1-7]\s' /var/log/apache2/access.log

# Cross-reference: same source IP should have a preceding mo_umpr_validate_otp call
# Absence of this call for the same IP+session window indicates bypass attempt
grep -P 'mo_umpr_validate_otp' /var/log/apache2/access.log
Forensic Artifacts

Successful exploitation writes a password reset key to the user_activation_key column of wp_users (UM uses WordPress’s native get_password_reset_key()). Query for accounts with active reset keys:

SELECT ID, user_login, user_activation_key
FROM wp_users
WHERE user_activation_key != ''
ORDER BY ID;

A non-empty user_activation_key for an account whose owner did not initiate a password reset indicates exploitation. Cross-reference with the password_rst_attempts user meta to detect repeated exploit attempts:

SELECT u.user_login, um.meta_value AS reset_attempts
FROM wp_usermeta um
JOIN wp_users u ON u.ID = um.user_id
WHERE um.meta_key = 'password_rst_attempts'
  AND CAST(um.meta_value AS UNSIGNED) > 0;

8. References

SourceURL
NVDhttps://nvd.nist.gov/vuln/detail/CVE-2026-61967
Patchstack Advisoryhttps://patchstack.com/database/wordpress/plugin/miniorange-otp-verification/vulnerability/wordpress-miniorange-otp-verification-plugin-5-5-1-privilege-escalation-vulnerability
WordPress Pluginhttps://wordpress.org/plugins/miniorange-otp-verification/
Vendor Patch (5.5.2)https://downloads.wordpress.org/plugin/miniorange-otp-verification.5.5.2.zip