4837 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / check.py PY
#!/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()