-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcheck.py
More file actions
571 lines (421 loc) · 20.8 KB
/
check.py
File metadata and controls
571 lines (421 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
#!/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': '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 = "<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()