Cacti 是一款开源的运维监控工具,使用 PHP、MySQL/MariaDB 编写,提供友好的界面。该漏洞于 2023 年被发现,影响 1.2.24 之前的所有版本。此安全缺陷在于向 SQL 查询中插入值时实现不当。这是一个严重的 SQL 注入漏洞,允许攻击者修改数据库以及远程执行代码。
在本分析中,为简单起见,我将在 Docker 中运行 Cacti。首先,我们创建如下所示的 docker-compose.yml 文件,并运行命令 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:
构建容器后,我们可以通过访问 http://localhost:80 来访问 Cacti。下一步是将 Cacti 更新到 1.2.24 版本。你可以在此处下载升级脚本:https://pastebin.com/NfRiHLjR。将文件保存到与 docker-compose.yml 相同的目录,并将其重命名为 upgrade_cacti.sh。然后运行以下两个命令:
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"
现在我们可以开始了,让我们深入研究代码,看看是什么导致了 SQL 注入攻击。存在漏洞的文件是 graph_view.php,我们只需要 guest 用户即可访问此文件,从而允许任何人利用此漏洞。在该文件中,不安全的函数是 grow_right_pane_tree()。

在深入分析此函数之前,我们需要回溯了解该函数是如何被执行的。
<?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);
}
从上面的代码片段可以看出,当 $tree_id > 0 时,该函数将会被执行。所有步骤可以解释如下:
首先,程序从用户输入中获取 action 参数的值,并进入一个 switch/case 语句。
在 tree_content 分支中,代码从用户处获取请求参数 node。接着,它通过 - 字符分割输入,保存到 $part,最后检查 node 中是否包含 tree_anchor。如果所有条件都匹配,$tree_id 将被赋值为 $part 中的第二个元素。例如,tree_content-1 或 1-2-tree_content 都是有效的,$tree_id 的值将相应地设定。
至此,我们的 URL 看起来像这样:http://localhost:80?action=tree_content&node=tree_anchor-1。现在是时候分析 grow_right_pane_tree() 函数来利用这个漏洞了。

易受攻击的参数是 rfilter,它被直接传递到 WHERE 子句中的 RLIKE 操作数中。使其易受攻击的原因是 rfilter 被双引号 " 包围,但该参数由 html_validate_tree_vars() 函数检查,该函数如下所示:
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 ================= */
// ........
}
rfilter 的过滤器类型设置为 FILTER_VALIDATE_IS_REGEX,并存储在 $filters 中,然后 $filter 被传递到 validate_store_request_vars() 函数中。让我们深入了解这个函数,这将揭示我们分析的其余部分:
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;
}
validate_store_request_vars() 函数负责验证用户输入。当 filter 类型为 FILTER_VALIDATE_IS_REGEX 时,该函数调用 validate_is_regex() 函数,该函数使用 preg_match 来验证我们的输入是否有效。根据 PHP 文档:
preg_match() 如果
pattern匹配给定的subject,则返回 1;如果不匹配,则返回 0;如果失败,则返回 false。
if 语句使用了严格比较,这意味着只有在发生错误时,我们才无法进入该 if 语句。$regex(即我们的输入)被包裹在两个单引号 ' 之间。开发者的意图是防止用户注入通常用于发起 SQL 注入 攻击的单引号 '。这看起来是一个健壮的安全实现,但让我们再看一下 rfilter 是如何被传递到 WHERE 子句中的:
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
啊哦,验证用户输入的努力变得毫无意义,因为 rfilter 被包裹在双引号 " 内,这允许攻击者轻松地注入 " 来跳出该语句。
理解了为什么它易受攻击之后,现在我们将深入研究 $sql_where 被传递到的查询。

在 $sql_where 被赋值之后,程序将调用 get_allowed_tree_header_graphs() 函数。该函数如下所示:
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");