[CVE-2023-39361] Unauthenticated SQL injection in Cacti v1.2.24

·

6 min read

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 rfilterFILTER_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.