#!/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 = " bool: """Check if open.php is accessible (allows ticket creation without account) Returns: (accessible: bool, details: str) """ print("\n[*] Checking open ticket endpoint...") open_url = urljoin(base_url, "open.php") try: resp = session.get(open_url, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) if resp.status_code != 200: print(f" [~] open.php returned status {resp.status_code}") return False content = resp.text.lower() # Check if redirected to login (means login required for new tickets) if "login.php" in resp.url or resp.url.endswith("login.php"): # noqa: PLR2004 print(" [+] Redirected to login (ticket creation requires authentication)") return False # Look for new ticket form indicators ticket_form_indicators = ["ajax.php/form/help-topic", "select a help topic"] form_found = " list[int]: """Extract help topic IDs from the open.php page. Topics control which dynamic forms are loaded. Returns: list of topic IDs """ # Look for topicId select options or AJAX form loading # Pattern: topic_pattern = re.compile(r']*value=["\'](\d+)["\'][^>]*>(?!.*?Select.*?Topic)', re.IGNORECASE) matches = topic_pattern.findall(content) # Also check for default/preselected topic default_pattern = re.compile(r'name=["\']topicId["\'][^>]*value=["\'](\d+)["\']', re.IGNORECASE) default_matches = default_pattern.findall(content) topic_ids = list(set(matches + default_matches)) return [int(tid) for tid in topic_ids if tid.isdigit()] def extract_csrf_token(content: str) -> str | None: """Extract CSRF token from form Returns: token string or None """ # Look for common CSRF token patterns patterns = [ r'name=["\']__CSRFToken__["\'][^>]*value=["\']([^"\']+)["\']', r'name=["\']csrf_token["\'][^>]*value=["\']([^"\']+)["\']', r']*type=["\']hidden["\'][^>]*name=["\'][^"\']*token[^"\']*["\'][^>]*value=["\']([^"\']+)["\']', ] for pattern in patterns: match = re.search(pattern, content, re.IGNORECASE) if match: return match.group(1) return None def get_html_enabled_topic(base_url, session: requests.Session) -> int | None: """Get a topic ID that supports HTML/rich-text submission. Returns: topic_id (int) or None """ open_url = urljoin(base_url, "open.php") try: resp = session.get(open_url, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) if resp.status_code != 200: return None content = resp.text topic_ids = extract_topic_ids(content) # Check each topic for HTML support for topic_id in topic_ids: if check_topic_forms_html_support(base_url, session, topic_id): return topic_id # If no topics found, check if default form has HTML support if check_default_form_html_support(content): # Return first topic or None if no topics return topic_ids[0] if topic_ids else None except requests.RequestException: # noqa: S110 pass return None def test_html_submission(base_url: str, session: requests.Session) -> bool: """Test HTML content submission to detect CVE-2026-22200 patch status. Submits an INVALID ticket (missing required fields) with a benign img srcset attribute. The patch (v1.18.3/v1.17.7) strips srcset attributes from img tags in submitted HTML. - PATCHED: srcset attribute is stripped from response - VULNERABLE: srcset attribute is preserved in response Returns: bool (True if vulnerable, False if patched or inconclusive) """ print("\n[*] Testing for CVE-2026-22200 patch status...") print(" [*] Detection method: img srcset attribute sanitization check") open_url = urljoin(base_url, "open.php") # Unique marker to detect in response - benign, not an exploit attempt patch_marker = "PATCH_DETECT_7f3a9b2e" try: # First GET to extract form structure and tokens resp = session.get(open_url, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) if resp.status_code != 200: print(" [~] Cannot test (open.php not accessible)") return False content = resp.text # Extract CSRF token if present csrf_token = extract_csrf_token(content) # Extract topic IDs topic_id = get_html_enabled_topic(base_url, session) if not topic_id: print(" [+] Cannot test (no richtext-enabled topic found)") return False print(f" [!] Found topic_id that supports rich text message: {topic_id}") # Build test payload with CSS url() in inline style # The patch strips ALL url() from inline styles (class.format.php lines 281-285) # Using a benign marker - this is NOT an exploit attempt test_html = f'Office landscape' payload = { "a": "open", "subject": "Test Ticket Submission", "message": test_html, "name": "Test User", # Intentionally OMIT email to cause validation failure # 'email': 'test@example.com', # <-- NOT PROVIDED } if csrf_token: payload["__CSRFToken__"] = csrf_token if topic_id: payload["topicId"] = topic_id print(" [*] Submitting test payload (will fail validation - no email):") # POST the invalid form resp = session.post(open_url, data=payload, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) print(f" [*] Server response status: {resp.status_code}") response_content = resp.text response_lower = response_content.lower() # Look for validation error messages (proof form was processed) validation_indicators = [ "required", "error", "email address", "correct", ] has_validation_error = any(ind in response_lower for ind in validation_indicators) if not has_validation_error: print(" [-] No clear validation error detected - cannot determine patch status") return False print(" [+] Form processed and returned validation error (as expected)") url_pattern_preserved = "srcset" in response_lower and f"http://{patch_marker.lower()}.example.com" in response_lower if url_pattern_preserved: print(" [!] VULNERABLE - srcset attribute was NOT stripped") print(" [!] Target appears to be running osTicket < v1.18.3 / < v1.17.7") print(" [*] The php:// filter stream wrapper may be exploitable") # Show context around the url pattern start_index = response_lower.find("srcset") excerpt_start = max(0, start_index - 50) excerpt_end = min(len(response_content), start_index + 200) print("\n Response excerpt:") print(f" {response_content[excerpt_start:excerpt_end]}") return True else: print(" [+] PATCHED - srcset attribute was stripped from response") return False except requests.RequestException as e: print(f" [!] Error testing submission: {e}") return False def check_default_form_html_support(content: str) -> bool: """Check the default form loaded on open.php for HTML support Returns: (bool, dict) """ rich_text_indicators = ['class="richtext'] has_rich_text = any(indicator in content.lower() for indicator in rich_text_indicators) if has_rich_text: print(" [!] Rich-text/HTML editor detected in default form") return True else: return False def check_topic_forms_html_support(base_url: str, session: requests.Session, topic_id: int) -> bool: """Check if a specific help topic loads forms with HTML support. osTicket dynamically loads forms via AJAX when topic is selected. Returns: bool """ # Try the AJAX endpoint that loads topic forms ajax_url = urljoin(base_url, f"ajax.php/form/help-topic/{topic_id}/forms") try: resp = session.get(ajax_url, timeout=REQUESTS_TIMEOUT, headers={"X-Requested-With": "XMLHttpRequest"}, verify=False) if resp.status_code == 200: content = resp.text.lower() # Check for rich text indicators in the AJAX response rich_text_indicators = [ 'class="richtext', ] return any(indicator in content for indicator in rich_text_indicators) else: # AJAX endpoint not available or different structure # Fall back to checking via direct topic selection return check_topic_via_direct_load(base_url, session, topic_id) except requests.RequestException: # If AJAX fails, try direct approach return check_topic_via_direct_load(base_url, session, topic_id) def check_topic_via_direct_load(base_url: str, session: requests.Session, topic_id: int) -> bool: """Load open.php with a specific topicId parameter and check for HTML support Returns: bool """ try: open_url = urljoin(base_url, f"open.php?topicId={topic_id}") resp = session.get(open_url, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) if resp.status_code == 200: content = resp.text.lower() rich_text_indicators = ['class="richtext'] return any(indicator in content for indicator in rich_text_indicators) except requests.RequestException: # noqa: S110 pass return False def check_ticket_status_access(base_url: str, session: requests.Session) -> str: """Check if view.php is accessible for checking ticket status Returns: (accessible: bool, details: str) """ print("\n[*] Checking ticket status/view endpoint...") view_url = urljoin(base_url, "view.php") try: resp = session.get(view_url, timeout=REQUESTS_TIMEOUT, allow_redirects=True, verify=False) if resp.status_code != 200: print(f" [~] view.php returned status {resp.status_code}") return False, f"HTTP {resp.status_code}" content = resp.text.lower() # Look for ticket access/status form access_indicators = ['id="ticketno"', 'name="lticket"'] form_found = " None: """Print final consolidated verdict based on detection results. Args: login_result: Result from login validation check ("vulnerable", "patched", or None) submission_result: Result from HTML submission check (True=vulnerable, False=patched, None=not run) """ print(f"\n{'=' * 70}") print("FINAL VERDICT") print(f"{'=' * 70}") # Determine overall status is_vulnerable = False is_patched = False if login_result == "vulnerable": is_vulnerable = True elif login_result == "patched": is_patched = True # submission_result: True = vulnerable, False = patched/inconclusive if submission_result is True: is_vulnerable = True if is_patched: print("[+] Target is LIKELY PATCHED against CVE-2026-22200") print("[+] Running osTicket v1.18.3+ or v1.17.7+") elif is_vulnerable: print("[!] Target is LIKELY VULNERABLE to CVE-2026-22200") print("[!] Recommend upgrading to osTicket v1.18.3+ or v1.17.7+") if self_registration_enabled or submission_result is True: print("[!] Target is LIKELY EXPLOITABLE by anonymous attackers") else: print("[~] Target is LIKELY NOT EXPLOITABLE by anonymous attackers") else: print("[~] Could not determine patch status") print("[~] Manual verification recommended") def main(): parser = argparse.ArgumentParser( description="Unauthenticated check for osTicket CVE-2026-22200", epilog="Example: python3 check.py https://support.example.com", ) parser.add_argument("base_url", help="Base URL of the osTicket installation") args = parser.parse_args() base_url = args.base_url.rstrip("/") + "/" # Validate URL parsed = urlparse(base_url) if not parsed.scheme or not parsed.netloc: print("[!] Invalid URL provided") sys.exit(1) print_banner() print(f"[*] Target: {base_url}\n") session = create_session() session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"}) self_registration_enabled = check_account_registration(base_url, session) login_result = check_login_validation(base_url, session) submission_result = None open_ticket_accessible = check_open_ticket_access(base_url, session) if open_ticket_accessible: check_ticket_status_access(base_url, session) submission_result = test_html_submission(base_url, session) # Print final consolidated verdict print_final_verdict(self_registration_enabled, login_result, submission_result) print("\n[*] Check complete\n") if __name__ == "__main__": main()