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 time
		ini_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_item
				WHERE host_id = ?
				AND local_data_id = ?',
				array($host_id, $local_data_id));

			$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));

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

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

						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 array
			if ($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
		// version
		if ($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}'";

 完成 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小帅一把手

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值