Cacti-远程命令执行分析[CVE-2022-46169]
原创 深蓝 2022-12-16 00:00 北京
前言
Cacti项目是一个开源平台,可为用户提供强大且可扩展的操作监控和故障管理框架。
由于remote_agent.php中的case POLLER_ACTION_SCRIPT_PHP 在使用proc_open函数时未对传入的poller_id参数做严格过滤,攻击者可构造满足条件的payload对相关目标系统进行命令注入,导致远程命令执行。
影响范围:
Cacti == 1.2.22
正文
由于没有公开的poc,参考官方修复方案与漏洞描述进行poc挖掘与复现。
环境搭建参考:
https://blog.csdn.net/katrina0602/article/details/103710840
主要看remote_agent.php:

在进行下一步操作前会调用remote_client_authorized()进行身份验证:

首先会通过get_client_addr()函数获取client_addr,然后从数据库中获取pollers对象集合逐一比对poller的hostname字段,而get_client_addr()会从下述header头中取出client_addr的值。

要满足上述条件,显然我们需要数据库中有一个poller满足hostname与我们通过上述header头传输的client_addr值相等:

同时经过测试X-Forwarded-For,Forwarded-For ,Client-ip以HTTP开头字段(经p师傅指点)能满足上述传值的条件:

至此我们成功绕过了身份校验访问到了remote_agent.php:

接下来,会进入一个action的case逻辑中,可以看到polldata的逻辑中会调用poll_for_data()函数:

function poll_for_data() {global $config;$local_data_ids = get_nfilter_request_var('local_data_ids');$host_id = get_filter_request_var('host_id');$poller_id = get_nfilter_request_var('poller_id');$return = array();$i = 0;if (cacti_sizeof($local_data_ids)) {foreach($local_data_ids as $local_data_id) {input_validate_input_number($local_data_id);$items = db_fetch_assoc_prepared('SELECT *FROM poller_itemWHERE host_id = ?AND local_data_id = ?',array($host_id, $local_data_id));$script_server_calls = db_fetch_cell_prepared('SELECT COUNT(*)FROM poller_itemWHERE host_id = ?AND local_data_id = ?AND action = 2',array($host_id, $local_data_id));if (cacti_sizeof($items)) {foreach($items as $item) {switch ($item['action']) {case POLLER_ACTION_SNMP: /* snmp */if (($item['snmp_version'] == 0) || (($item['snmp_community'] == '') && ($item['snmp_version'] != 3))) {$output = 'U';} else {$host = db_fetch_row_prepared('SELECT ping_retries, max_oids FROM host WHERE hostname = ?', array($item['hostname']));$session = cacti_snmp_session($item['hostname'], $item['snmp_community'], $item['snmp_version'],$item['snmp_username'], $item['snmp_password'], $item['snmp_auth_protocol'], $item['snmp_priv_passphrase'],$item['snmp_priv_protocol'], $item['snmp_context'], $item['snmp_engine_id'], $item['snmp_port'],$item['snmp_timeout'], $host['ping_retries'], $host['max_oids']);if ($session === false) {$output = 'U';} else {$output = cacti_snmp_session_get($session, $item['arg1']);$session->close();}if (prepare_validate_result($output) === false) {if (strlen($output) > 20) {$strout = 20;} else {$strout = strlen($output);}$output = 'U';}}$return[$i]['value'] = $output;$return[$i]['rrd_name'] = $item['rrd_name'];$return[$i]['local_data_id'] = $local_data_id;break;case POLLER_ACTION_SCRIPT: /* script (popen) */$output = trim(exec_poll($item['arg1']));if (prepare_validate_result($output) === false) {if (strlen($output) > 20) {$strout = 20;} else {$strout = strlen($output);}$output = 'U';}$return[$i]['value'] = $output;$return[$i]['rrd_name'] = $item['rrd_name'];$return[$i]['local_data_id'] = $local_data_id;break;case POLLER_ACTION_SCRIPT_PHP: /* script (php script server) */$cactides = array(0 => array('pipe', 'r'), // stdin is a pipe that the child will read from1 => array('pipe', 'w'), // stdout is a pipe that the child will write to2 => array('pipe', 'w') // stderr is a pipe to write to);if (function_exists('proc_open')) {$cactiphp = proc_open(read_config_option('path_php_binary') . ' -q ' . $config['base_path'] . '/script_server.php realtime ' . $poller_id, $cactides, $pipes);$output = fgets($pipes[1], 1024);$using_proc_function = true;} else {$using_proc_function = false;}if ($using_proc_function == true) {$output = trim(str_replace("\n", '', exec_poll_php($item['arg1'], $using_proc_function, $pipes, $cactiphp)));if (prepare_validate_result($output) === false) {if (strlen($output) > 20) {$strout = 20;} else {$strout = strlen($output);}$output = 'U';}} else {$output = 'U';}$return[$i]['value'] = $output;$return[$i]['rrd_name'] = $item['rrd_name'];$return[$i]['local_data_id'] = $local_data_id;if (($using_proc_function == true) && ($script_server_calls > 0)) {/* close php server process */fwrite($pipes[0], "quit\r\n");fclose($pipes[0]);fclose($pipes[1]);fclose($pipes[2]);$return_value = proc_close($cactiphp);}break;}$i++;}}}}print json_encode($return);}
首先会从请求中获取到$local_data_ids,$host_id,$poller_id三个参数:

get_filter_request_var会对传入的值进行过滤:

get_nfilter_request_var可以获取多组数据但未进行过滤。
function get_nfilter_request_var($name, $default = '') {global $_CACTI_REQUEST;if (isset($_CACTI_REQUEST[$name])) {return $_CACTI_REQUEST[$name];} elseif (isset($_REQUEST[$name])) {return $_REQUEST[$name];} else {return $default;}}
获取完相关参数后,接下来会从poller_item这张表中取出所有符合满足条件的items进行遍历:


在查询出的item满足POLLER_ACTION_SCRIPT_PHP也就是2时,将会把获取到的poller_id拼接到proc_open命令中,配合前面提到的poller_id的传输未进行任何相关过滤操作,造成命令注入,并最终执行任意命令:
case POLLER_ACTION_SCRIPT_PHP: /* script (php script server) */$cactides = array(0 => array('pipe', 'r'), // stdin is a pipe that the child will read from1 => array('pipe', 'w'), // stdout is a pipe that the child will write to2 => array('pipe', 'w') // stderr is a pipe to write to);if (function_exists('proc_open')) {$cactiphp = proc_open(read_config_option('path_php_binary') . ' -q ' . $config['base_path'] . '/script_server.php realtime ' . $poller_id, $cactides, $pipes);$output = fgets($pipes[1], 1024);$using_proc_function = true;} else {$using_proc_function = false;}



后记
官方补丁修复:
对get_client_addr()方法进行了重写


并用包含过滤逻辑的get_filter_request_var()函数来获取poller_id参数:

针对PHP <5.4的版本:
