README.md
Rendering markdown...
#!/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 = "<form" in content and any(ind in content for ind in registration_indicators) # noqa: PLR2004
# Check if login-only (no registration)
login_only = "login" in content and not any(ind in content for ind in registration_indicators) # noqa: PLR2004
if form_found:
print(" [!] Account registration appears ENABLED")
return True
elif login_only:
print(" [+] Only login form found (registration disabled or private)")
return False
else:
print(" [~] No clear registration form found")
return False
except requests.RequestException as e:
print(f" [!] Error accessing account.php: {e}")
return False
def check_open_ticket_access(base_url: str, session: requests.Session) -> 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 = "<form" in content and any(ind in content for ind in ticket_form_indicators) # noqa: PLR2004
if form_found:
print(" [!] Open ticket form is ACCESSIBLE (no login required)")
return True
else:
print(" [~] No ticket form found on open.php")
return False
except requests.RequestException as e:
print(f" [!] Error accessing open.php: {e}")
return False
def extract_topic_ids(content: str) -> 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: <option value="123">Topic Name</option>
topic_pattern = re.compile(r'<option[^>]*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'<input[^>]*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'<img src="doesnotexist.jpg" srcset="http://{patch_marker}.example.com/image-400.jpg 400w, http://{patch_marker}.example.com/image-800.jpg 800w, http://{patch_marker}.example.com/image-1200.jpg 1200w, http://{patch_marker}.example.com/image-1600.jpg 1600w" alt="Office landscape" width="800" height="600" data-image="vgmd0ykzb2uq">'
payload = {
"a": "open",
"subject": "Test Ticket Submission",
"message": test_html,
"name": "Test User",
# Intentionally OMIT email to cause validation failure
# 'email': '[email protected]', # <-- 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 = "<form" in content and any(ind in content for ind in access_indicators) # noqa: PLR2004
if form_found:
print(" [!] Ticket status check form is ACCESSIBLE")
return True
else:
print(" [~] No ticket status form detected")
return False
except requests.RequestException as e:
print(f" [!] Error accessing view.php: {e}")
return False
def print_final_verdict(self_registration_enabled:bool, login_result: str | None, submission_result: bool | None) -> 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()