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