
Step-by-step analysis and lab setup for CVE-2023-39361, an unauthenticated SQL injection in Cacti v1.2.24, with exploitation and mitigation walkthrough.
Cacti is an open-source operational monitoring tool written in PHP, MySQL/MariaDB, which provides a friendly interface. The vulnerability was found in 2023 which affected all versions before 1.2.24. This security flaws lies in the improper implementation when inserting values into SQL query. This is a critical SQL Injection vulnerability that allow attackers to modify database as well as execution code remotely.
In this analysis, I will run Cacti in Docker for simplicity. First, we will create the file docker-compose.yml as below and run command docker-compose up -d.
version: '3.5'
services:
cacti:
image: "smcline06/cacti"
container_name: CVE-2023-39361
domainname: example.com
hostname: cacti
ports:
- "80:80"
- "443:443"
environment:
- DB_NAME=cacti_master
- DB_USER=cactiuser
- DB_PASS=cactipassword
- DB_HOST=db
- DB_PORT=3306
- DB_ROOT_PASS=rootpassword
- INITIALIZE_DB=1
- TZ=America/Los_Angeles
volumes:
- cacti-data:/cacti
- cacti-spine:/spine
- cacti-backups:/backups
links:
- db
db:
image: "mariadb:10.3"
container_name: CVE-2023-39361_db
domainname: example.com
hostname: db
ports:
- "3306:3306"
command:
- mysqld
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --max_connections=200
- --max_heap_table_size=128M
- --max_allowed_packet=32M
- --tmp_table_size=128M
- --join_buffer_size=128M
- --innodb_buffer_pool_size=1G
- --innodb_doublewrite=ON
- --innodb_flush_log_at_timeout=3
- --innodb_read_io_threads=32
- --innodb_write_io_threads=16
- --innodb_buffer_pool_instances=9
- --innodb_file_format=Barracuda
- --innodb_large_prefix=1
- --innodb_io_capacity=5000
- --innodb_io_capacity_max=10000
environment:
- MYSQL_ROOT_PASSWORD=rootpassword
- TZ=America/Los_Angeles
volumes:
- cacti-db:/var/lib/mysql
volumes:
cacti-db:
cacti-data:
cacti-spine:
cacti-backups:
After building the container, we can access to Cacti by browsing to http://localhost:80. The next step is to update Cactii to version 1.2.24. You can download the upgrade script here: https://pastebin.com/NfRiHLjR. Save the file at the same directory with the docker-compose.yml file and rename it to upgrade_cacti.sh. Then run these 2 commands:
docker cp upgrade_cacti.sh CVE-2023-39361:/tmp/upgrade_cacti.sh && docker exec -it CVE-2023-39361 bash -c "bash /tmp/upgrade_cacti.sh"
Now we're good to go, let's dive into the code to see what happened that allows us to have a SQL injection attack. The vulnerable file is graph_view.php and we only need guest user to access to this file, leading to allow everyone to exploit this vulnerability. In this file, the insecure function is grow_right_pane_tree().

Before, we dive deeply into this function, we need to trace backwards to know how the functions is executed.
<?php
switch (get_nfilter_request_var('action')) {
//....
// Many other cases
case 'tree_content':
//..... Some code here
// ---------
if (isset_request_var('node')) {
$parts = explode('-', sanitize_search_string(get_request_var('node')));
// Check for tree anchor
if (strpos(get_nfilter_request_var('node'), 'tree_anchor') !== false) {
$tree_id = $parts[1];
$node_id = 0;
}
//..... Some code here
if ($tree_id > 0) {
if (!is_tree_allowed($tree_id)) {
header('Location: permission_denied.php');
exit;
}
// Vulnerable function is called here
grow_right_pane_tree($tree_id, $node_id, $hgdata);
}
From above code snippet, we can see that the function will be executed if $tree_id > 0. All the step can be explained as below:
First, the program will take the value of action parameter from user's input and step in a switch/case statement.
In the case tree_content, the code will take the request parameter node from user. Next, it will split the input by the - character, save to $part and finally check if tree_anchor appears in node. If all conditions match, $tree_id will be assign as the second element in $part. For example, tree_content-1 or 1-2-tree_content will be valid and the value of $tree_id will be respectively.
From this point, our URL will look like this: http://localhost:80?action=tree_content&node=tree_anchor-1. Now it's time to analyze the grow_right_pane_tree() function to exploit the bug.

The vulnerable parameter is rfilter which is directly passed into the RLIKE operand inside WHERE clause. What make it vulnerable is that rfilter is wrap between the double quote ", yet the parameter is checked by the html_validate_tree_vars() function, which looks like this:
function html_validate_tree_vars() {
/* ================= input validation and session storage ================= */
$filters = array(
// .......
'rfilter' => array(
'filter' => FILTER_VALIDATE_IS_REGEX,
'pageset' => true,
'default' => '',
),
// ..........
);
validate_store_request_vars($filters, 'sess_grt');
/* ================= input validation ================= */
// ........
}
The filter type of rfilter is set to FILTER_VALIDATE_IS_REGEX and stored inside $filters, then $filter is passed into validate_store_request_vars() function. Let's dive into this function, this will reveal the rest of our analysis:
function validate_store_request_vars($filters, $sess_prefix = '') {
// .......
if (cacti_sizeof($filters)) {
foreach($filters as $variable => $options) {
// ....
elseif ($options['filter'] == FILTER_VALIDATE_IS_REGEX) {
if (is_base64_encoded($_REQUEST[$variable])) {
$_REQUEST[$variable] = base64_decode($_REQUEST[$variable]);
}
$valid = validate_is_regex($_REQUEST[$variable]);
if ($valid === true) {
$value = $_REQUEST[$variable];
} else {
$value = false;
$custom_error = $valid;
}
}
// ........
function validate_is_regex($regex) {
// ........
if (@preg_match("'" . $regex . "'", NULL) !== false) {
ini_set('track_errors', $track_errors);
return true;
}
The validate_store_request_vars() function is responsible for validating user input. When the filter type is FILTER_VALIDATE_IS_REGEX, the function calls the validate_is_regex() function with the preg_match function to verify if our input is valid. According to PHP documentation:
preg_match() returns 1 if the
patternmatches givensubject, 0 if it does not, or false on failure.
The if statement use Strict comparison meaning that only when error occurs that we cannot step into the if statement. The $regex, which is our input, is wrapped between two single quote '. The intention of the developers is to avoid user inject the single quote ', which is often used to start a SQL injection attack. This seems to be a robust security implementation, yet let's have a look one more time again on how the rfilter is passed into the WHERE clause:
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
Ah-oh, The effort of validating user's input become meaningless as the rfilter is wrapped inside the double quotes ", which allows attackers to easily injection " to break out of the statement.
Having understood why it is vulnerable, now we will go deeper to the query where $sql_where is passed into.