[CVE-2023-39361] Unauthenticated SQL injection in Cacti v1.2.24
Description
1.Cacti
Cacti là một công cụ giám sát mạng dựa trên PHP/ MySQL sử dụng RRDTool (Round-robin database tool) với mục đích lưu trữ dữ liệu và tạo đồ họa. Cacti thu thập dữ liệu định kì thông qua Net-SNMP (một bộ phần mềm dùng để thực hiện SNMP-Simple Network Management Protocol).
2.CVE-2023-39361
Đây là lỗ hổng Unauthenticated SQLi ảnh hưởng tới phiên bản 1.2.24.Bản vá cho lỗ hổng này là phiên bản 1.2.25 và 1.2.30.Lỗ hổng SQLi được phát hiện ở trang graph_view.php
và vì guest users cũng có thể truy cập vào trang này nên bất kì ai cũng có thể khai thác được lỗ hổng này.
Patch Analysis
Tiến hành tải bản có lỗi 1.2.24 và bản patch 1.2.25 sau đó mình diff hai bản bằng vscode.
Thấy có sự thay đổi nhỏ ở hàm grow_right_pane_tree
dòng 1286 của file lib/html_tree.php
.Dấu nháy kép khi truyền tham số rfilter
được đổi thành nháy đơn.Vậy giờ ta đã biết điểm mà cần chú ý ở đâu.Giờ thì setup và phân tích.
Setup
Để tiện nhất mình dùng docker setup môi trường.File docker-compose.yml
như sau:
version: '3.5'
services:
cacti:
image: "smcline06/cacti"
container_name: cacti
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: cacti_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:
Sau đó chạy docker-compose up
.Tuy nhiên docker trên là bản 1.2.17 do đó ta cần truy cập vào container chạy cacti và upgrade lên 1.2.24.
Tải file upgrade_1.2.24 rồi chạy file này và ta sẽ có bản 1.2.24
Analysis
Tìm nơi gọi hàm grow_right_pane_tree
trong file graph_view.php
.
graph_view.php
switch (get_nfilter_request_var('action')) {
// ...
case 'tree_content':
html_validate_tree_vars();
// ...
if ($tree_id > 0) {
if (!is_tree_allowed($tree_id)) {
header('Location: permission_denied.php');
exit;
}
grow_right_pane_tree($tree_id, $node_id, $hgdata);
}
Lỗ hổng nằm ở hàm grow_right_pane_tree
trong chức năng graph view.Trong case tree_content
input của user dược kiểm tra bằng hàm html_validate_tree_vars()
sau đó hàm grow_right_pane_tree
được gọi khi tham số tree_id
lớn hơn 0.
lib/html_tree.php
function grow_right_pane_tree($tree_id, $leaf_id, $host_group_data) {
// ...
if (($leaf_type == 'header') || (empty($leaf_id))) {
$sql_where = '';
if (get_request_var('rfilter') != '') {
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
}
// ...
$graph_list = get_allowed_tree_header_graphs($tree_id, $leaf_id, $sql_where);
}
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');
Hàm grow_right_pane_tree
truyền trực tiếp input của user thông qua param rfilter
vào sau toán tử RLIKE
của mệnh đề WHERE
.Nhưng trước đó rfilter
đã dược hàm html_validate_tree_vars()
kiểm tra.Hàm html_validate_tree_vars()
đặt kiểu filter cho rfilter
là FILTER_VALIDATE_IS_REGEX
và gọi hàm validate_store_request_vars
.
lib/html_utility.php
function validate_store_request_vars(array $filters, string $sess_prefix = ''):void {
// ...
if (cacti_sizeof($filters)) {
foreach ($filters as $variable => $options) {
// Establish the session variable first
if ($sess_prefix != '') {
// ...
} else {
if (get_nfilter_request_var($variable) == '0') {
// ...
} elseif ($options['filter'] == FILTER_VALIDATE_IS_REGEX) {
if (is_base64_encoded($_REQUEST[$variable])) {
$_REQUEST[$variable] = base64_decode($_REQUEST[$variable], true);
}
$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;
}
hàm validate_store_request_vars
sẽ kiểm tra input của user nếu kiểu filter là FILTER_VALIDATE_IS_REGEX
thì hàm validate_is_regex
sẽ đươc gọi để kiểm tra input bằng cách dùng preg_match
.=>Như ta có thể thấy từ đoạn code trên thì kiểu filter FILTER_VALIDATE_IS_REGEX
sẽ kiểm tra input của người dùng có tồn tại dấu nháy đơn không nếu có thì sẽ bị dấu nháy ngoài escape.
Tuy nhiên,quay trở lại hàm grow_right_pane_tree
và quan sát đoạn code ban đầu ở phần Patch Analysis.
function grow_right_pane_tree($tree_id, $leaf_id, $host_group_data) {
// ...
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
ta có thể thấy biến rfilter
được truyền vào trong dấu nháy kép -> Điều này có nghĩa là cái filter cho param rfilter
không có ý nghĩa gì khi nó chỉ filter input có dấu nháy đơn.Như vậy nếu ta truyền dấu nháy kép vào input thì sẽ không bị filter đồng thời cũng escape được câu truy vấn ban đầu.
POC
Account Takeover
Tìm đến nơi xử lý đăng nhập của user:
function local_auth_login_process($username) {
$user = array();
if (!api_plugin_hook_function('login_process', false)) {
$user = secpass_login_process($username);
/**
* If the password needs to be rehashed for security purposes,
* do that now.
*/
$stored_pass = db_fetch_cell_prepared('SELECT password
FROM user_auth
WHERE username = ?
AND realm = 0',
array($username));
if ($stored_pass != '') {
$password = get_nfilter_request_var('login_password');
$valid = compat_password_verify($password, $stored_pass);
cacti_log("DEBUG: User '" . $username . "' password for rehash is " . ($valid ? '':'in') . 'valid', false, 'AUTH', POLLER_VERBOSITY_DEBUG);
if ($valid) {
$user = db_fetch_row_prepared('SELECT *
FROM user_auth
WHERE username = ?
AND realm = 0',
array($username));
if (compat_password_needs_rehash($stored_pass, PASSWORD_DEFAULT)) {
$password = compat_password_hash($password, PASSWORD_DEFAULT);
db_check_password_length();
db_execute_prepared('UPDATE user_auth
SET password = ?
WHERE username = ?',
array($password, $username));
}
}
}
}
return $user;
}
Sau khi kiểm tra tính hợp lệ của username ở hàm secpass_login_process
,server sẽ tiến hành kiểm tra password được nhập với password trong db bằng compat_password_verify
:
function compat_password_verify($password, $hash) {
if (function_exists('password_verify')) {
if (password_verify($password, $hash)) {
return true;
}
}
$md5 = md5($password);
return ($md5 == $hash);
}
tiếp tục gọi đến password_verify.Theo như doc của php thì pasword_verify
sẽ kiểm tra hash được đưa vào có match với password không bằng cách sử dụng hàm password_hash.Nếu để ý phía dưới ta có thể thấy server sử dụng hàm compat_password_hash
rồi gọi tới password_hash
để băm password bằng thuật toán mặc định tức thuật toán bcrypt
function compat_password_hash($password, $algo, $options = array()) {
if (function_exists('password_hash')) {
// Check if options array has anything, only pass when required
return (cacti_sizeof($options) > 0) ?
password_hash($password, $algo, $options) :
password_hash($password, $algo);
}
return md5($password);
}
Như vậy ta đã biết được cách thức mà server sẽ lưu password của user dưới dạng mã bcrypt. Attacker có thể lợi dụng điều này để thay đổi password và chiếm đoạt tài khoản của user khác.
<?php
$password="fake_password";
$password=password_hash($password,PASSWORD_DEFAULT);
echo $password;
Result: $2y$10$.4/6G5Ag8QodIHiIxn5gRuiFP5Wl3KZySwgcPmeHTMGN638srXJz.
Tài khoản admin đã bị chiếm đoạt.
All rights reserved