关联漏洞
标题:
Cacti SQL注入漏洞
(CVE-2023-39361)
描述:Cacti是Cacti团队的一套开源的网络流量监测和分析工具。该工具通过snmpget来获取数据,使用RRDtool绘画图形进行分析,并提供数据和用户管理功能。 Cacti 存在SQL注入漏洞,该漏洞源于默认情况下,访客用户可以在不经过身份验证的情况下访问graph_view.php。
介绍
[CVE-2023-39361] Unauthenticated SQL injection in Cacti v1.2.24
=============
## Overview
**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**.
## Lab Setup:
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
<?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:
```php
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:
```php
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 `pattern` matches given `subject`, 0 if it does not, or **[false](https://www.php.net/manual/en/reserved.constants.php#constant.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.

After $sql_where is assigned, the program will call the `get_allowed_tree_header_graphs()` function. The function will look like this:
```php
function get_allowed_tree_header_graphs($tree_id, $leaf_id = 0, $sql_where = '', $sql_order = 'gti.position', $sql_limit = '', &$total_rows = 0, $user_id = 0) {
// ......
if ($sql_where != '') {
$sql_where = " AND ($sql_where)";
}
$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;
$graphs = db_fetch_assoc("SELECT gti.id, gti.title, gtg.local_graph_id, h.description, gt.name AS template_name, gtg.title_cache, gtg.width, gtg.height, gl.snmp_index, gl.snmp_query_id
FROM graph_templates_graph AS gtg
INNER JOIN graph_local AS gl
ON gl.id = gtg.local_graph_id
INNER JOIN graph_tree_items AS gti
ON gti.local_graph_id = gl.id
LEFT JOIN graph_templates AS gt
ON gt.id = gl.graph_template_id
LEFT JOIN host AS h
ON h.id = gl.host_id
$sql_where
$sql_order
$sql_limit");
$sql = "SELECT COUNT(*)
FROM graph_templates_graph AS gtg
INNER JOIN graph_local AS gl
ON gl.id=gtg.local_graph_id
INNER JOIN graph_tree_items AS gti
ON gti.local_graph_id=gl.id
LEFT JOIN graph_templates AS gt
ON gt.id=gl.graph_template_id
LEFT JOIN host AS h
ON h.id=gl.host_id
$sql_where";
$total_rows = get_total_row_data($user_id, $sql, array(), 'graph');
return $graphs;
}
```
This is where the `$sql_where` is passed into a query. Note that the variable is assigned 2 more times before being passed in, which are `$sql_where = " AND ($sql_where)";` and `$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;`. Combine with the first assignment, this is how the `$sql_where` is finally created:
```php
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
$sql_where = " AND ($sql_where)";
$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;
```
After these 3 assignments, the `$sql_where` will look like this:
```php
$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id) AND ((gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '"))";
```
The query that `$sql_where` is passed into looks overwhelming, yet we can create another simple query with similar structure:
`select * from users WHERE (TRUE) AND` **`((username RLIKE "' get_request_var('rfilter') '"`** `-- something behind that isn't neccessary))`
In order to successfully inject our payload, we need to break out of the `"` and `))`. Therefore, this payload may work `"));SELECT SLEEP(5) --`. Our payload will look like this, `select * from users WHERE (TRUE) AND` **`((username RLIKE "'"));SELECT SLEEP(5) --`. Looks good! Let's try on Cacti

The reason the server doesn't sleep for 2 seconds is because we caused error in the `preg_match()` function. Let's look at the function again
```
if (@preg_match("'" . $regex . "'", NULL) !== false) {
ini_set('track_errors', $track_errors);
return true;
}
```
This function will take our input as `regular expression`. And the `)` is used to group a set of characters. Because it's not closed, so the function returned error. On the other hand, if we try `"(());SELECT SLEEP(5) --`, we cannot break out of the `((` from the SQL query. However, there a trick here, and it's is `"OR"(("));SELECT SLEEP(5) --`. Let me explain this, I wrap `((` in double quote to make it as a string in SQL query and use `OR` to match with the` username` before it. The, double quote `"` in regular expression, however, is just considered as a normal character, that's why we can take advantage of this feature to break out of the SQL query.

Based on the response time, we can make sure that we successfully injected SQL into the database. This is a critical SQL injection vulnerability. The attacker can leverage this bug to **take over** admin account or even execute command remotely.
## Mitigation
As usual, injection vulnerabilities in general occur due to the absence of input validation. However, in this case, the developers applied validations, yet improperly. In order to fix this, we just simply wrap the `rfilter` parameter inside the single quote `'` instead of double quote `"`

With this simple fix, the effort of validating user's input will now work properly. Let's try the payload again to see if it's still vulnerable.

With the same payload but now the response is really fast, meaning that we cannot inject SQL command anymore.
----
From this CVE analysis, we can learn that even minor mistake can also result in a catastrophic outcome. We should carefully review the code and frequently test our website to make it more and more secure. This is the end of the analysis, hope you learnt something useful today. Happy hacking!
文件快照
[4.0K] /data/pocs/94a41503c01c7ee1324350689398ce31074b2c25
├── [4.0K] images
│ ├── [183K] failed_injection_1.png
│ ├── [179K] fixed_request.png
│ ├── [ 17K] fix.png
│ ├── [ 36K] grow_right_pane_tree_analyze.png
│ ├── [ 11K] grow_right_pane_tree.png
│ ├── [ 32K] grow_right_pane_tree_review.png
│ ├── [183K] successful_injection.png
│ └── [ 38K] tree_id.png
└── [ 13K] README.md
1 directory, 9 files
备注
1. 建议优先通过来源进行访问。
2. 如果因为来源失效或无法访问,请发送邮箱到 f.jinxu#gmail.com 索取本地快照(把 # 换成 @)。
3. 神龙已为您对POC代码进行快照,为了长期维护,请考虑为本地POC付费,感谢您的支持。