
Пошаговый анализ и настройка лабораторной среды для CVE-2023-39361, неавторизованной SQL-инъекции в Cacti v1.2.24, с прохождением эксплуатации и смягчения последствий.
Cacti — это инструмент мониторинга с открытым исходным кодом, написанный на PHP, MySQL/MariaDB, предоставляющий удобный интерфейс. Уязвимость была обнаружена в 2023 году и затрагивает все версии до 1.2.24. Эта проблема безопасности заключается в некорректной реализации при вставке значений в SQL-запрос. Это критическая уязвимость SQL-инъекции, которая позволяет атакующим изменять базу данных, а также удаленно выполнять код.
В этом анализе для простоты я запущу Cacti в Docker. Сначала создадим файл 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:
После сборки контейнера мы можем получить доступ к Cacti, перейдя по адресу http://localhost:80. Следующий шаг — обновить Cacti до версии 1.2.24. Вы можете загрузить скрипт обновления здесь: https://pastebin.com/NfRiHLjR. Сохраните файл в той же директории, где находится файл docker-compose.yml, и переименуйте его в upgrade_cacti.sh. Затем выполните следующие 2 команды:
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, и для доступа к этому файлу нам нужен только гостевой пользователь, что позволяет любому использовать эту уязвимость. В этом файле небезопасная функция — 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 и, наконец, проверяет, присутствует ли tree_anchor в node. Если все условия совпадают, $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, который напрямую передается в операнд RLIKE внутри предложения WHERE. Что делает его уязвимым, так это то, что 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() возвращает 1, если
patternсоответствует заданномуsubject, 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.