#!/usr/bin/env python3 """osTicket PDF File Read Check (CVE-2026-22200) Validates if remote osTicket installation is vulnerable to a local file read CVE-2026-22200 that is exploitable by anonymous/guest users. Example: python3 check.py https://support.example.com """ import argparse import re import sys from urllib.parse import urljoin, urlparse import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry requests.packages.urllib3.disable_warnings() REQUESTS_TIMEOUT = 20 def print_banner(): """Print script banner""" print("=" * 70) print("osTicket CVE-2026-22200 Check") print("=" * 70) def create_session() -> requests.Session: """Create requests session with retry logic""" session = requests.Session() retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) return session def check_login_validation(base_url: str, session: requests.Session) -> str | None: """Check if login.php validates username format. The patch (v1.18.3/v1.17.7) adds Validator::is_userid() check before calling the authentication backend. This validates username format. Detection: Submit login with invalid username chars (e.g., containing '|') - PATCHED: Returns "Invalid User Id" (validation fails early) - VULNERABLE: Returns "Access Denied" or "Invalid username or password" (no pre-validation) Returns: - "vulnerable" if unpatched - "patched" if patched - None if inconclusive """ print("\n[*] Testing login validation...") print(" [*] Detection method: Username format pre-validation check") login_url = urljoin(base_url, "login.php") try: # First GET to extract CSRF token resp = session.get(login_url, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) if resp.status_code != 200: print(f" [-] login.php returned status {resp.status_code}") return None content = resp.text # Check if this is the login page (not redirected elsewhere) if "luser" not in content.lower() and "userid" not in content.lower(): print(" [+] Login form not found on page") return None # Extract CSRF token csrf_token = extract_csrf_token(content) if not csrf_token: print(" [-] Could not extract CSRF token") return None # Use an invalid username with characters that fail is_username() validation # is_username() requires: /^[\p{L}\d._-]+$/u (letters, digits, dots, underscores, hyphens) # The pipe character '|' is invalid and will fail validation invalid_username = "test|invalid<>user" payload = { "__CSRFToken__": csrf_token, "luser": invalid_username, "lpasswd": "testpassword123", } print(f" [*] Submitting login with invalid username format: {invalid_username}") # POST the login attempt resp = session.post(login_url, data=payload, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) print(f" [*] Server response status: {resp.status_code}") response_lower = resp.text.lower() # Check for the specific error messages # Patched: "Invalid User Id" (from Validator::is_userid) # Vulnerable: "Invalid username or password" (from auth backend) # CSRF failure: "Access denied" (CSRF token validation failed) has_invalid_userid = "invalid user id" in response_lower has_invalid_username_password = "invalid username or password" in response_lower has_access_denied = "access denied" in response_lower if has_invalid_userid: print(" [+] PATCHED - Username format validation is active") print(" [+] Server returned: \"Invalid User Id\"") print(" [+] Target appears to be running osTicket >= v1.18.3 / >= v1.17.7") return "patched" else: # If we don't get "Invalid User Id", then Validator::is_userid() is NOT being called, # which means the patch is NOT applied. The patch adds is_userid() check before # calling the auth backend, so absence of this validation = VULNERABLE. if has_invalid_username_password: print(" [!] VULNERABLE - Server returned: \"Invalid username or password\"") return "vulnerable" elif has_access_denied: print(" [!] VULNERABLE - Server returned: \"Access denied\"") return "vulnerable" else: print(" [~] Server did not return \"Invalid User Id\"") return None except requests.RequestException as e: print(f" [!] Error testing login: {e}") return None def check_account_registration(base_url: str, session: requests.Session) -> bool: """Check if public account registration is enabled at account.php Returns: (enabled: bool, details: str) """ print("\n[*] Checking account registration endpoint...") account_url = urljoin(base_url, "account.php") try: resp = session.get(account_url, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) if resp.status_code != 200: print(f" [~] account.php returned status {resp.status_code}") return False, f"HTTP {resp.status_code}" content = resp.text.lower() # Look for registration form indicators registration_indicators = ["passwd2", "create a password", "confirm new password"] form_found = "